From 784373a254420bcd2851f9e48ae94904dff3612d Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Tue, 16 Jun 2026 14:41:28 +0000 Subject: [PATCH 01/20] =?UTF-8?q?docs:=20handoff=2073=20=E2=80=94=20Ctrl-G?= =?UTF-8?q?=20demo-mode=20F1=20alias=20(ADR-0047=20Amendment=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/handoff/20260616-handoff-73.md | 112 ++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 docs/handoff/20260616-handoff-73.md diff --git a/docs/handoff/20260616-handoff-73.md b/docs/handoff/20260616-handoff-73.md new file mode 100644 index 0000000..e3dce65 --- /dev/null +++ b/docs/handoff/20260616-handoff-73.md @@ -0,0 +1,112 @@ +# Session handoff — 2026-06-16 (73) + +Short, focused handover. Continues from handoff-72 (which completed the +H2 hint-corpus verification pass). This session shipped one small +feature — a **`Ctrl-G` demo-mode alias for F1** — plus follow-on doc +hygiene. Commit `4016c3e`. + +## §1. State + +**Branch:** `main`, clean, all committed (local; **push pending** — your +step; the backlog now spans the CI merge, H2, the hint-corpus fixes, +handoffs 71/72/73, and this Ctrl-G commit). **2503 pass / 0 fail / 1 +ignored** (the long-standing `friendly` doctest), **clippy clean** +(nursery, all targets). Open Gitea issues unchanged: **#35–#38**. + +## §2. Why Ctrl-G (the problem) + +Casts are recorded with **`autocast`** (ADR-0047 demo mode: `--demo` / +`RDBMS_PLAYGROUND_DEMO`). The contextual hint overlay (ADR-0053 / H2) +opens on **F1** — but F1 reaches the app only as a terminal **escape +sequence** (`\eOP` / `\e[11~`), and **autocast cannot emit escape +sequences**. So the single most teaching-relevant overlay was +unreachable in recordings (and in presenter/teacher sessions, which also +run `--demo`). Same wall that pushed step-captions onto `Ctrl+]` (a +single control byte) rather than `Ctrl+!`. + +### Chord choice — why Ctrl-G, why not Ctrl-1 + +The user's first instinct was `Ctrl-1` (mnemonic, near F1). **Not +possible:** in a legacy terminal `Ctrl`+digit has no control byte — +`Ctrl-1` arrives as a bare `1` (would type "1" into the buffer). The +kitty keyboard protocol *would* encode it, but only as an escape +sequence (the very thing autocast can't send), and this app deliberately +does **not** push `KeyboardEnhancementFlags` (`runtime.rs` does only +`enable_raw_mode` + `EnterAlternateScreen`). So the usable space is +exactly **`Ctrl`+letter** (single legacy control bytes). After excluding +taken chords (`Ctrl-C` quit, `Ctrl-O` nav, `Ctrl+]` caption, +`Ctrl-A/E/W/K/U` readline per ADR-0049), byte-collisions +(`Ctrl-H/I/J/M/[`), flow-control (`Ctrl-S/Q`), and likely-future +(`Ctrl-R/P/N/Y/L/V`), **`Ctrl-G`** is the clean survivor (BEL/"abort" in +line editors — nothing destructive to shadow). + +## §3. What shipped (commit `4016c3e`) + +ADR-0047 **Amendment 1**. **In demo mode only**, `Ctrl-G` aliases F1: + +- Runs the *exact* F1 hint logic (`hint_key` guard in + `App::handle_key`, `src/app.rs` ~line 1226 — `key.code == F(1) || + (self.demo_mode && Ctrl-G)`), so live-input → form hint, empty input → + recent-error / getting-started. +- **Badges as `[F1]`** (not `[CTRL-G]`): `demo_badge_label` maps + `Ctrl-G → Some("[F1]")` (consulted only in demo mode — the caller + gates). So a recorded cast is **visually identical to a real F1 + press**. +- **Demo-gated:** the shipped keymap stays F1-only. Outside demo mode + `Ctrl-G` falls through to the inert catch-all (the `Char(c)` insert arm + excludes CONTROL, so no `g` is typed). +- The keybinding strip (ADR-0051) is **not** changed — F1 stays the + advertised key; `Ctrl-G` is a recorder aid and the badge already reads + `[F1]`. + +**Tests (test-first, `src/app.rs` Tier-1):** `ctrl_g_in_demo_mode_- +aliases_f1_on_input`, `…_on_empty_input`, `ctrl_g_outside_demo_mode_- +is_inert`, plus a `Ctrl-G → [F1]` assertion added to +`demo_badge_label_maps_the_invisible_keys`. All confirmed red→green; the +"inert" test passed on the pre-change code, proving the demo-gate. + +### Using it in a cast +With `--demo` active, send **`Ctrl-G`** in the autocast script wherever +you want the hint overlay to appear; the viewer sees the `[F1]` badge. + +## §4. Doc hygiene done alongside (same commit) + +- **ADR-0047 file:** removed two stray `` / `` lines + (tool-call residue accidentally committed when the ADR was authored). +- **CLAUDE.md "Things deliberately deferred":** dropped three **stale** + entries — **I1b** readline shortcuts (done, ADR-0049), **I3** tab + completion, and **I4** syntax highlighting (both done; requirements.md + even carries a 2026-06-07 reconciliation note that I3/I4 were + "shipped but marked not yet"). Only **I1** (multi-line input) remains — + it is genuinely still open (`[ ]` in requirements.md). *(This follows + the handoff-72 cleanup that removed the equally-stale m:n/C4 + + project-storage entries.)* + +## §5. Open / next (unchanged from handoff-72 §5) + +The hint corpus is trustworthy and the cast-F1 gap is closed. Roadmap: + +1. **Push** (your step). +2. **#35 (cargo fmt gate)** — precondition (CI merged) is met; the user + wants it done once, before first publication. Needs a `rustfmt.toml`- + vs-defaults decision first; tree is ~1800 hunks dirty. +3. Other open `requirements.md` items: **I1** multi-line input, **I5/B3** + in-flight cancellation, **TT4** PTY tier-4 (unwired), **DOC1**/**E2** + user docs (partial), **TT5** Windows-execution + Tier-4-in-CI, **D3** + packaging manifests. Design-first (`[~]`): **V4** session journal, + **TU1** tutorial, **C3a**, **V3**. +4. Hint follow-ups if wanted: **#36** `help` advanced-SQL, **#37** hint + clause-concepts, **#38** hint diagnostic route. + +## §6. How to take over + +1. Read handoffs 71 → 72 → 73, `CLAUDE.md`, `docs/requirements.md`. +2. Confirm green: `cargo test` (**2503 / 1 ignored**) + `cargo clippy + --all-targets` (clean). +3. For demo-mode / casting, read **ADR-0047** (+ its Amendment 1); for + the hint overlay it aliases, **ADR-0053**. +4. Workflow unchanged: phased, test-first, `/runda` + DA before commits, + ADR amendment + README index-upkeep for decided-area changes, confirm + commit messages with the user. +5. Consider a `cargo sweep` at this milestone (`target/` grows; see + CLAUDE.md "Build hygiene"). From 628b250db6f3776edef997a0f1bd52e5645ae011 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Tue, 16 Jun 2026 15:06:49 +0000 Subject: [PATCH 02/20] docs: reconcile docs after ci+website merges; gitignore wrangler/vscode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post-merge documentation accuracy pass (CI and website branches both merged to main; website deployed). - CLAUDE.md: rewrite the stale repository-layout tree; add a Website & docs-site decision bullet (Astro+Starlight, Cloudflare Pages via Gitea Actions, ADR-website-001, the website branch stays open); update the CI note (merged to main; release-macos dispatchable + verified working). - requirements.md: D1 — macOS targets now runtime-verified (release-macos dispatched end-to-end); DOC1 — canonical user docs now live on the deployed website. - ADR-ci-003 (+ docs/ci/adr README): Amendment 2 — CI on main, release-macos dispatched + verified; macOS runtime-verified. - docs/website/adr README: drop the stale "no CI yet". - .gitignore: ignore .wrangler/ (Cloudflare Wrangler cache) and .vscode/; remove the tracked website/.vscode/ an Astro template had added. D3 (package-manager manifests) + some install instructions remain open. --- .gitignore | 6 ++ CLAUDE.md | 97 +++++++++++++++++++----------- docs/ci/adr/20260613-adr-ci-003.md | 11 ++++ docs/ci/adr/README.md | 2 +- docs/requirements.md | 12 +++- docs/website/adr/README.md | 2 +- website/.vscode/extensions.json | 4 -- website/.vscode/launch.json | 11 ---- 8 files changed, 91 insertions(+), 54 deletions(-) delete mode 100644 website/.vscode/extensions.json delete mode 100644 website/.vscode/launch.json diff --git a/.gitignore b/.gitignore index 5b370c4..053b40e 100644 --- a/.gitignore +++ b/.gitignore @@ -12,7 +12,13 @@ *.snap.new *.pending-snap +# Website tooling — Cloudflare Wrangler local cache/state (regenerable; +# CI deploys from website/, this dir only appears on a local wrangler run) +.wrangler/ + # Editor / OS .DS_Store *.swp *.swo +# Astro/template-seeded editor configs we don't track (e.g. website/.vscode) +.vscode/ diff --git a/CLAUDE.md b/CLAUDE.md index 952c2a3..6038711 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,10 +108,11 @@ Current decisions at a glance (each backed by an ADR): SQL `select` / `with` / `insert` / `update` / `delete` (ADR-0039). `EXPLAIN QUERY PLAN` never executes, so explaining a destructive command is safe. -- **Continuous integration & release** (built on the `ci` branch, - 2026-06-15; decisions in `docs/ci/adr/` — **ADR-ci-001/002/003**, - a namespace kept separate from the main ADR sequence to avoid - cross-branch number collisions, like the website's): a self-hosted +- **Continuous integration & release** (developed on the `ci` branch, + **merged to `main` 2026-06-15**; decisions in `docs/ci/adr/` — + **ADR-ci-001/002/003**, a namespace kept separate from the main ADR + sequence to avoid cross-branch number collisions, like the website's): + a self-hosted **Gitea Actions** pipeline built on a **nix flake** (pinned Rust `1.95.0` — one source of toolchain for dev *and* CI) plus a prebuilt CI image. **Gate** (`ci.yaml`): `clippy -D warnings` + @@ -122,44 +123,72 @@ Current decisions at a glance (each backed by an ADR): `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`. + (the tree isn't stock-`rustfmt`-clean). Now that this is on `main`, + `release-macos` is dispatchable (`workflow_dispatch` is + Gitea-default-branch-only) — **dispatched and verified working**: the + macOS build + de-nix/re-sign + upload runs end-to-end and the binaries + launch. +- **Website & docs site** (developed on the `website` branch, **merged + to `main`**; the branch **stays open** for future staging deploys; + decisions in `docs/website/adr/` — **ADR-website-001**, its own + namespace like CI's): an **Astro + Starlight + Tailwind** marketing + landing page plus the **canonical** user docs, under `website/`. + Showcase demos are **asciinema casts** (a scripted-input driver, paced + + re-recordable — *not* `history.log` replay; the `--demo` overlay, + ADR-0047, dresses them). **Deployed to Cloudflare Pages via Gitea + Actions** (`.gitea/workflows/website.yaml`; the crate gate is skipped + for website-only changes). Two copy rules bind user-facing text: **no + engine name** (continues ADR-0002) and **no "DSL"** (say "simple mode" + / "advanced mode"). Install docs are still partial — package-manager + manifests + some installation instructions are pending (`requirements.md` + D3 / DOC1). ## Repository layout ``` . -├── Cargo.toml # dependencies, lints (nursery) -├── CLAUDE.md # this file +├── Cargo.toml / Cargo.lock # dependencies, lints (nursery) +├── CLAUDE.md # this file +├── flake.nix / flake.lock # pinned Rust toolchain, one source for dev + CI (ADR-ci-002) +├── rust-toolchain.toml # toolchain pin +├── .gitea/ # Gitea Actions workflows + prebuilt CI image (ADR-ci-001..003, website) +├── ci/ # CI build helpers (e.g. winstub/ — Windows link stub) ├── docs/ -│ ├── adr/ # all decision records (read 0000 first) -│ ├── handoff/ # session-handover notes -│ └── requirements.md # the Phase-1 checklist with progress +│ ├── adr/ # project-wide decision records (read 0000 first) +│ ├── ci/{adr,handoff}/ # CI subproject ADRs (ci-001..003) + handoffs +│ ├── website/{adr,plans}/ # website subproject ADRs (website-001) + plan +│ ├── handoff/ # session-handover notes +│ ├── plans/ # working plans +│ └── requirements.md # the Phase-1 checklist with progress ├── src/ -│ ├── action.rs # Action enum (Quit / ExecuteDsl) -│ ├── app.rs # App state + pure update() + Tier-1 tests -│ ├── cli.rs # CLI args (--theme, --log-file) -│ ├── db.rs # rusqlite worker, all DDL/DML, metadata tables +│ ├── action.rs # Action enum (Quit / ExecuteDsl / …) +│ ├── app.rs # App state + pure update() + Tier-1 tests +│ ├── cli.rs # CLI args (--theme, --log-file, --demo, --no-undo, --resume, …) +│ ├── clipboard.rs # copy output to the system clipboard +│ ├── completion.rs # Tab completion + schema cache +│ ├── db.rs # rusqlite worker, all DDL/DML, metadata tables │ ├── dsl/ -│ │ ├── action.rs # ReferentialAction enum + parsing -│ │ ├── command.rs # Command AST + RelationshipSelector + RowFilter -│ │ ├── mod.rs # re-exports -│ │ ├── parser.rs # parse entry point → unified-grammar walker -│ │ ├── shortid.rs # base58 generator + validator -│ │ ├── types.rs # user-facing Type enum + fk_target_type -│ │ └── value.rs # Value/Bound + per-type validation -│ ├── event.rs # AppEvent (input + DSL outcomes) -│ ├── lib.rs # module re-exports for tests -│ ├── logging.rs # tracing setup, file-backed -│ ├── main.rs # binary entry; thin -│ ├── mode.rs # Simple/Advanced mode enum -│ ├── runtime.rs # Tokio loop, terminal setup, dispatch -│ ├── snapshots/ # insta snapshots for Tier-2 tests -│ ├── theme.rs # light/dark themes -│ └── ui.rs # ratatui rendering -└── tests/ - └── walking_skeleton.rs # Tier-3 integration tests +│ │ ├── grammar/ # hand-rolled unified grammar nodes (DSL + SQL) +│ │ ├── walker/ # grammar walker (driver / context / highlight / outcome) +│ │ ├── command.rs parser.rs types.rs value.rs action.rs shortid.rs sql_functions.rs +│ ├── echo.rs # command echo / SQL rendering +│ ├── event.rs # AppEvent (input + DSL outcomes) +│ ├── friendly/ # friendly-error layer + string catalog (strings/en-US.yaml) + keys +│ ├── input_render.rs # input-field render + ambient hint classification +│ ├── output_render.rs # output-panel render helpers (incl. relationship diagrams) +│ ├── logging.rs main.rs mode.rs runtime.rs # tracing / entry / mode enum / Tokio loop +│ ├── persistence/ # csv + yaml + history IO + migrations +│ ├── project/ # open/create, lock, naming, prettifier +│ ├── seed/ # seed generators / heuristics / vocabulary (ADR-0048) +│ ├── snapshots/ # insta snapshots for Tier-2 tests +│ ├── theme.rs type_change.rs ui.rs undo.rs # themes / column type-change / render / undo ring +│ └── lib.rs # module re-exports for tests +├── tests/ +│ ├── it/ # Tier-3 integration tests (consolidated into one binary) +│ └── typing_surface_matrix.rs # typing-surface matrix (separate Tier-3 target) +└── website/ # Astro + Starlight docs/marketing site (ADR-website-001) + ├── src/ public/ casts-src/ # pages + assets + asciinema cast sources + └── astro.config.mjs package.json … # deploys to Cloudflare Pages via Gitea Actions ``` Key invariants in the code: diff --git a/docs/ci/adr/20260613-adr-ci-003.md b/docs/ci/adr/20260613-adr-ci-003.md index 882e9d8..269e44a 100644 --- a/docs/ci/adr/20260613-adr-ci-003.md +++ b/docs/ci/adr/20260613-adr-ci-003.md @@ -20,6 +20,17 @@ This ADR records the **cross-platform build strategy**; it sits on top of **ADR-ci-002** (the nix flake, which now carries the cross toolchain) and **ADR-ci-001** (the pipeline, whose release job this fills in). +## Amendment 2 — 2026-06-16: CI on `main`; `release-macos` dispatched + verified + +The CI branch is **merged to `main`**, so `release-macos.yaml` +(`workflow_dispatch`, Gitea-default-branch-only) is now triggerable. It +has been **dispatched and verified end-to-end**: both `*-apple-darwin` +targets build on the Tart runner, the de-nix/re-sign step runs, the +assets upload to the tagged release, and the binaries launch. macOS is +therefore runtime-verified (alongside the original Linux x86_64 + +Windows aarch64); only **Linux aarch64** and **Windows x86_64** remain +link-clean / valid-format without a runtime smoke-test. + ## Amendment — 2026-06-14: macOS implemented (closes D1) macOS is no longer deferred. The two `*-apple-darwin` targets now build on a diff --git a/docs/ci/adr/README.md b/docs/ci/adr/README.md index d2a5e66..7d1136b 100644 --- a/docs/ci/adr/README.md +++ b/docs/ci/adr/README.md @@ -20,4 +20,4 @@ here too). - [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). +- [ADR-ci-003 — Cross-platform release builds (the D1 matrix)](20260613-adr-ci-003.md) — **Accepted 2026-06-13** (implemented + a real matrix release verified the same day — tag `v.0.0.0-citest3` published 8 assets). Cross-compiles the **four non-macOS D1 targets** from the Linux x86_64 runner with **`cargo-zigbuild`** (Zig's bundled clang + libc as one universal cross cc/linker, incl. rusqlite's bundled SQLite C; added to the flake devShell, replacing the single-target musl cc): **`x86_64`/`aarch64-unknown-linux-musl`** (musl + crt-static → fully static, **D2**) and **`x86_64-pc-windows-gnu`** / **`aarch64-pc-windows-gnullvm`** (Zig statically links libc → standalone `.exe`). **Windows `synchronization` stub:** Rust std links `-lsynchronization` (WaitOnAddress thread-parking), an import lib rust-overlay's toolchain doesn't ship and Zig's mingw lacks; the symbols are forwarded by `kernel32`, so an **empty 8-byte stub** `libsynchronization.a` (`ci/winstub/`, wired via `.cargo/config.toml` for the Windows targets only) satisfies the linker. **Workflow:** `release.yaml` = **`test` once (host) → `build` matrix** over the four targets (`needs: test`, `fail-fast: false`); each job packages binary (`.exe` for Windows) + `.sha256` and uploads to the **shared release** via idempotent create-or-get. **macOS** (2026-06-14 amendment) — built natively on a **Tart (Apple-Silicon) runner** (`runs-on: macos`), which makes the SDK fully licensed and dissolves the grey-area/public-image problem; `release-macos.yaml` is **dispatch-only** (intermittent runner), de-nixes the binary's libiconv load path (`install_name_tool` → `/usr/lib`) + re-signs ad-hoc, and uploads to the tagged release. **D1 complete (all six targets).** Alternatives rejected: `cross` (no macOS, needs DinD), per-target nix cross (Windows-aarch64 unpackaged, macOS-from-Linux unsupported), a real `libsynchronization.a` (more machinery, doesn't cover Windows-aarch64). **Amendment 2 (2026-06-16):** CI is **merged to `main`**, so `release-macos` is now triggerable (`workflow_dispatch` is default-branch-only) and has been **dispatched + verified end-to-end** (build → de-nix/re-sign → upload, binaries launch). Runtime-verified by the user: Linux x86_64, Windows aarch64, **and both macOS targets**; Linux aarch64 + Windows x86_64 are the outstanding runtime checks. Builds on ADR-ci-002 (flake) and fills in ADR-ci-001 §3 (Release). diff --git a/docs/requirements.md b/docs/requirements.md index f578a1c..a240981 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -70,8 +70,11 @@ since ADR-0027.) 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.)* + Runtime-verified by the user: Linux x86_64, Windows aarch64, and both + macOS targets (the `release-macos.yaml` dispatch — now triggerable + since CI is on `main` — was run end-to-end and the binaries launch); + Linux aarch64 + Windows x86_64 remain link-clean / valid-format + only.)* - [x] **D2** Single static binary, no runtime dependencies. *(Done 2026-06-15, per platform: **Linux** is fully static (musl + `crt-static`); **Windows** is a standalone `.exe` (Zig statically @@ -888,7 +891,10 @@ since ADR-0027.) exists (~55 lines, covers the WHERE-expression and table-creation boundaries). **Missing:** a DSL command-surface reference and a standalone type-system - reference under `docs/`.)* + reference under `docs/`. **Note (2026-06-16):** the **canonical** + user docs now live on the **website** (ADR-website-001, deployed) — + it covers the full feature set; the in-repo `docs/` reference pieces + named here remain the outstanding part of DOC1.)* ## Testing (per ADR-0008) diff --git a/docs/website/adr/README.md b/docs/website/adr/README.md index 8d03b3c..daab7d4 100644 --- a/docs/website/adr/README.md +++ b/docs/website/adr/README.md @@ -16,4 +16,4 @@ applies here too). ## Index -- [ADR-website-001 — Public website and documentation site](20260604-adr-website-001.md) — **Accepted 2026-06-04** (formerly ADR-0044 in the main index; moved here 2026-06-10 to end recurring cross-branch number collisions). The first public website: a marketing landing page plus the **canonical** user docs. Stack **Astro 6 + Starlight + Tailwind v4** (chosen over SvelteKit + Tailwind for a docs-heavy + marketing site; interactive bits as Astro islands). Showcase demos are **asciinema** `.cast` recordings (scripted-input driver for paced, re-recordable sessions — *not* `history.log` replay), reused inline in docs. The **in-page WASM playground is deferred** (OOS: deferred) behind a stable `Demo` seam, with the portable-core vs native-edge boundary recorded for a future ADR + iteration plan. Portable **static build** (**Cloudflare** target via **Gitea Actions**, host-agnostic); **no CI yet**; **monorepo** (`website/`). Docs cover the **full supported feature set** with "planned" callouts for the unshipped minority; two wording rules bind user-facing copy — **no engine name** (continues ADR-0002) and **no "DSL"** ("simple mode" / "advanced mode"). Install docs cover **prebuilt binaries + package managers** (D1–D3 track the release tooling). Plan: [`docs/website/plans/20260604-website-implementation-plan.md`](../plans/20260604-website-implementation-plan.md) +- [ADR-website-001 — Public website and documentation site](20260604-adr-website-001.md) — **Accepted 2026-06-04** (formerly ADR-0044 in the main index; moved here 2026-06-10 to end recurring cross-branch number collisions). The first public website: a marketing landing page plus the **canonical** user docs. Stack **Astro 6 + Starlight + Tailwind v4** (chosen over SvelteKit + Tailwind for a docs-heavy + marketing site; interactive bits as Astro islands). Showcase demos are **asciinema** `.cast` recordings (scripted-input driver for paced, re-recordable sessions — *not* `history.log` replay), reused inline in docs. The **in-page WASM playground is deferred** (OOS: deferred) behind a stable `Demo` seam, with the portable-core vs native-edge boundary recorded for a future ADR + iteration plan. Portable **static build** (**Cloudflare** target via **Gitea Actions**, host-agnostic); **monorepo** (`website/`). **§4 update (CI implemented):** the static→Cloudflare Pages deploy now runs via Gitea Actions (`.gitea/workflows/website.yaml`; the crate gate is skipped for website-only changes); both `website` and `main` are merged and the site is **deployed**. Docs cover the **full supported feature set** with "planned" callouts for the unshipped minority; two wording rules bind user-facing copy — **no engine name** (continues ADR-0002) and **no "DSL"** ("simple mode" / "advanced mode"). Install docs cover **prebuilt binaries + package managers** (D1–D3 track the release tooling). Plan: [`docs/website/plans/20260604-website-implementation-plan.md`](../plans/20260604-website-implementation-plan.md) diff --git a/website/.vscode/extensions.json b/website/.vscode/extensions.json deleted file mode 100644 index 22a1505..0000000 --- a/website/.vscode/extensions.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "recommendations": ["astro-build.astro-vscode"], - "unwantedRecommendations": [] -} diff --git a/website/.vscode/launch.json b/website/.vscode/launch.json deleted file mode 100644 index d642209..0000000 --- a/website/.vscode/launch.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "version": "0.2.0", - "configurations": [ - { - "command": "./node_modules/.bin/astro dev", - "name": "Development server", - "request": "launch", - "type": "node-terminal" - } - ] -} From fe9d58e03703d57e3afa7e12341eeb1f15636dcb Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Tue, 16 Jun 2026 15:57:48 +0000 Subject: [PATCH 03/20] docs: plan the road to public availability (versioning, install, packaging) --- docs/plans/20260616-public-availability.md | 141 +++++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 docs/plans/20260616-public-availability.md diff --git a/docs/plans/20260616-public-availability.md b/docs/plans/20260616-public-availability.md new file mode 100644 index 0000000..c9e6dbb --- /dev/null +++ b/docs/plans/20260616-public-availability.md @@ -0,0 +1,141 @@ +# Plan — road to public availability (versioning + install + packaging) + +Status: **planning** (2026-06-16). Captures the decisions taken in +discussion plus the open questions, so the work can proceed in steps. +The versioning piece is being implemented first and is recorded as a +decision in **ADR-0054**; the install-script and package-manager pieces +are roadmap items here until each is built. + +Scope note: this repo lives on the self-hosted Gitea at +`git.lazyeval.net` (`oli/rdbms-playground`); releases publish there +(ADR-ci-001/003). Publication branding uses the company name **Lazy +Evaluation Ltd → `lazyeval`**, not the personal `oli` username, for +anything user-facing (taps, buckets). + +--- + +## 1. Versioning + version surfaces — DECIDED (→ ADR-0054, building now) + +- **`Cargo.toml` `version` is the single source of truth.** `--version` + reads `env!("CARGO_PKG_VERSION")` (compile-time, no deps). +- **Two surfaces:** a `--version` / `-V` CLI flag (prints + exits, like + `--help`), and an in-app **`version`** app command. +- **CI discipline:** `release.yaml` gains a guard that asserts the pushed + tag `vX.Y.Z` equals `CARGO_PKG_VERSION`, and **fails the release on + mismatch** — so the binary's self-reported version, the release name, + and the downloaded asset always agree. +- **Release ritual:** bump `Cargo.toml` → commit → tag `v` + → push tag. (The guard enforces it.) +- Optional enrichment (not decided): a `build.rs` baking a git short-hash + + build date so non-tagged dev builds read `0.2.0 (a1b2c3d)`. Good for + bug reports; can be added later. + +--- + +## 2. `install.sh` (curl | sh) — DECIDED shape + +- **Hosted from the Gitea repo URL** on `git.lazyeval.net` (simplest): + `curl -fsSL https://git.lazyeval.net/oli/rdbms-playground/raw/branch/main/scripts/install.sh | sh` + (exact raw-URL form to confirm against the Gitea version). +- **Behaviour:** POSIX `sh`; detect `uname` OS+arch → target triple + (Linux → the **musl static** build, our universal Linux artifact); + query the latest release via the Gitea API + (`/repos/oli/rdbms-playground/releases/latest`) → tag → deterministic + asset name `rdbms-playground--`; download + **verify the + `.sha256`**; install to `~/.local/bin` (fallback `/usr/local/bin` with + a sudo prompt); `chmod +x`; print a PATH hint if needed. +- **macOS:** binaries are signed (see §4 signing note); a `curl` + download does **not** apply the Gatekeeper quarantine xattr, so the + installed binary runs without `xattr` faff. +- **Windows:** not `curl | sh` — provide a PowerShell `install.ps1` + (`irm … | iex`) and/or steer to Scoop/winget (§3). +- **Security posture:** HTTPS only; in-script checksum verification; + document the download-inspect-run alternative (`curl|sh` is a trust + tradeoff). +- **Deliverables we own now:** `scripts/install.sh` (+ later + `install.ps1`); confirm releases are **publicly downloadable**; decide + whether to also upload `install.sh` as a release asset for a stable + link. Website copy referencing the command is the **website branch's** + job (separate agent), later. + +--- + +## 3. D3 — package managers (roadmap; each layers on the release assets) + +Common thread: a manifest pointing at our checksummed assets + a +per-release step to bump it. Ordered cheapest → most gatekept. + +### 3a. `cargo binstall` +- **Bootstrapping matters (user-flagged):** `binstall` is **not** a + built-in cargo subcommand — users must install **`cargo-binstall`** + first (its own `curl|sh`/PowerShell installer, or + `cargo install cargo-binstall`). **Our instructions must say this.** +- Add `[package.metadata.binstall]` to `Cargo.toml` (pkg-url template → + our Gitea release assets; our naming already fits). +- **OPEN:** the frictionless `cargo binstall rdbms-playground` resolves + the crate via **crates.io**. Decision needed: **publish to crates.io?** + If not, document the `cargo binstall --git ` form instead. + *(Verify current cargo-binstall non-crates.io behaviour before + committing wording.)* + +### 3b. Scoop (Windows) +- A **bucket** repo under `lazyeval` on Gitea with a JSON manifest + (`.exe` URL + hash + `autoupdate`). Release job commits the bump. +- Users: `scoop bucket add lazyeval ; scoop install rdbms-playground`. + +### 3c. Homebrew (macOS/Linux) +- A **company-branded tap** — `lazyeval/homebrew-tap` (on Gitea) — with a + Ruby formula (per-arch `url` + `sha256`). Release job commits the bump. +- Users: `brew tap lazyeval/tap https://git.lazyeval.net/lazyeval/homebrew-tap` + then `brew install lazyeval/tap/rdbms-playground` (the explicit-URL tap + form, since the `user/repo` shorthand assumes GitHub). +- *"Notability bars"* = the acceptance criteria for the default + **homebrew-core** tap (must be sufficiently popular/maintained). Our + own `lazyeval` tap sidesteps that entirely — no review gate. + +### 3d. winget (Windows) +- Manifests are YAML PR'd to `microsoft/winget-pkgs` (reviewed by MS). +- **`wingetcreate` is Windows-only** (.NET) — no good without a Windows + runner. **Automatic path to evaluate first: `komac`** — a + cross-platform (Rust) winget manifest creator/submitter that runs on + our **Linux** CI. *(Verify komac's current capabilities/auth model.)* +- **Fallback:** a manual YAML PR per release — acceptable given releases + are infrequent (user-confirmed). + +### Cross-cutting (3a–3d) +- Two extra repos (tap + bucket) under `lazyeval`, with CI push + credentials — setup TBD (user: "we'll figure that out"). +- **`cargo-dist`/"dist"** automates installers + Homebrew + CI, but is + **GitHub-Actions/Releases-centric**; on self-hosted Gitea it won't drop + in cleanly (installer-script generation might be reusable). Likely + hand-roll the manifests + a small "update on release" job instead. + *(Verify cargo-dist's current Gitea support before fully ruling out.)* + +--- + +## Open decisions (need the user) + +1. **crates.io:** publish the crate? (changes the `cargo binstall` + + discoverability story). +2. **macOS signing — CONFIRMED BUG (2026-06-16).** `release-macos.yaml` + line 52 runs `codesign --force --sign -` (the `-` = **ad-hoc**), so a + downloaded binary is *not* properly signed — user-verified. The Apple + **Developer ID** identity was provisioned in the runner VM's keychain + (the CI agent asked for it) but the workflow never references it, so + it sits **unused**. Fix = a `release-macos.yaml` change: sign with + `codesign --sign "Developer ID Application: "` + against the keychain identity, **after** the `install_name_tool` + de-nix (which invalidates any signature), then ideally **notarize + + staple** (needs Apple notarytool creds — Apple ID + app-specific + password, or an App Store Connect API key — as runner secrets). A + distinct task from the version work; corrects the docs once landed. +3. **Issue tracking:** file Gitea issues for these (version; install.sh; + D3), or track via this plan + ADR only? + +## Sequencing + +1. **Version discipline** (ADR-0054) — `--version`/`-V` + `version` + command + CI tag-match guard + tests. **← building now.** +2. `scripts/install.sh` + confirm public download URLs. +3. Package managers, cheapest first: `cargo binstall` + Scoop → Homebrew + → winget. From c30a6114b99753458d05aa9a54e6968fc7f9ee0d Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Tue, 16 Jun 2026 15:57:54 +0000 Subject: [PATCH 04/20] feat(cli): --version/-V + in-app `version` command + release guard (ADR-0054) Cargo.toml version is the single source of truth, surfaced by a --version/-V CLI flag and an in-app `version` command (both via cli::version_text -> cli.version_line). release.yaml gains a guard that fails the release unless the v* tag equals v, keeping --version, the release name, and the asset in lockstep. New app command wired across grammar/REGISTRY/dispatch/usage/help/hint-corpus/keys; 6 test-first tests. Also fixes a stale "macOS deferred" comment in release.yaml. ADR-0054 + README index + plan-doc step 1. --- .gitea/workflows/release.yaml | 26 ++++++- ...release-versioning-and-version-surfaces.md | 71 +++++++++++++++++++ docs/adr/README.md | 1 + src/app.rs | 29 ++++++++ src/cli.rs | 49 +++++++++++++ src/dsl/command.rs | 5 ++ src/dsl/grammar/app.rs | 12 ++++ src/dsl/grammar/mod.rs | 1 + src/friendly/keys.rs | 5 ++ src/friendly/strings/en-US.yaml | 12 ++++ src/main.rs | 7 +- tests/typing_surface/mod.rs | 1 + 12 files changed, 215 insertions(+), 4 deletions(-) create mode 100644 docs/adr/0054-release-versioning-and-version-surfaces.md diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml index 14110ea..59ad49f 100644 --- a/.gitea/workflows/release.yaml +++ b/.gitea/workflows/release.yaml @@ -5,11 +5,15 @@ # 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. +# The two macOS targets are built separately by the dispatched +# release-macos.yaml (native Tart runner; ADR-ci-003 amendment), uploading to +# the same release. D3 package-manager manifests layer on later. # # Tests run once (host) before the matrix, so a tag can never publish untested -# code, even one pointing at a commit that was never gated on a branch. +# code, even one pointing at a commit that was never gated on a branch. The +# version guard (ADR-0054) refuses to publish a tag whose vX.Y.Z disagrees with +# Cargo.toml's version, keeping `--version`, the release name, and the asset in +# lockstep. name: release on: push: @@ -23,6 +27,22 @@ jobs: image: git.lazyeval.net/oli/rdbms-playground-ci:latest steps: - uses: actions/checkout@v4 + - name: version guard — tag must equal Cargo.toml version (ADR-0054) + shell: bash + env: + TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + # CARGO_PKG_VERSION is the single source of truth; the binary reports + # it via --version / the `version` command. Parse it from cargo + # metadata (node is in the CI image; avoids assuming jq). + VER=$(nix develop -c cargo metadata --no-deps --format-version 1 \ + | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>process.stdout.write(JSON.parse(s).packages[0].version))') + echo "tag=$TAG cargo=$VER" + if [ "$TAG" != "v$VER" ]; then + echo "ERROR: release tag '$TAG' != 'v$VER' (Cargo.toml). Bump Cargo.toml to the release version, commit, then retag (ADR-0054)." >&2 + exit 1 + fi - name: test run: nix develop -c cargo test --no-fail-fast diff --git a/docs/adr/0054-release-versioning-and-version-surfaces.md b/docs/adr/0054-release-versioning-and-version-surfaces.md new file mode 100644 index 0000000..c2a0378 --- /dev/null +++ b/docs/adr/0054-release-versioning-and-version-surfaces.md @@ -0,0 +1,71 @@ +# ADR-0054: Release versioning policy + version surfaces (`--version` / `version`) + +## Status + +Accepted — **implemented 2026-06-16** (plan: +`docs/plans/20260616-public-availability.md`, step 1). First step on the +road to public availability. Adds a `--version` / `-V` CLI flag and an +in-app `version` command, both reporting `CARGO_PKG_VERSION`, plus a +release-CI guard that the `v*` tag equals that version. No prior issue or +`requirements.md` item existed for this — it was an untracked gap. + +## Context + +Before this, `Cargo.toml` carried `version = "0.1.0"`, the binary exposed +**no** way to report its version, and `release.yaml` named assets from the +**git tag** (`rdbms-playground--`) while building from +`Cargo.toml`. Tag and crate version were **decoupled**: tagging `v0.2.0` +would publish an asset named `…-v0.2.0-…` containing a binary that (had it +been able to say) reported `0.1.0`. On the way to public availability — +where users download a versioned artifact and file bug reports against "the +version I'm running" — that drift is a correctness problem. + +## Decision + +1. **`Cargo.toml` `version` is the single source of truth.** This is the + idiomatic Rust position and avoids making `Cargo.toml` lie. The version + is read at compile time via `env!("CARGO_PKG_VERSION")`; no build-time + injection of the tag into the crate. + +2. **Two user-facing surfaces, one source:** + - **`--version` / `-V`** — CLI flag (hand-rolled parser, mirrors + `--help`): prints and exits before any other work (`main.rs`). + - **`version`** — an in-app app command (REGISTRY node `app::VERSION`, + `AppCommand::Version`), emitting the same line into the output panel. + Both go through `cli::version_text()` → the catalog key + `cli.version_line` (`"rdbms-playground {version}"`), so there is exactly + one rendered string and one version source. + +3. **Release-CI discipline.** `release.yaml`'s pre-build `test` job gains a + **version guard**: it parses `CARGO_PKG_VERSION` from `cargo metadata` + and **fails the release** unless the pushed tag equals `v`. + So `--version`, the release name, and the downloaded asset are always in + lockstep — enforced by the machine, not by memory. + +4. **The release ritual:** bump `Cargo.toml` → commit → tag `v` → push the tag. The guard rejects any deviation. + +### Rejected / deferred +- **Inject the tag into the build** (tag as source of truth): fiddly with + cargo and makes `Cargo.toml` a placeholder/lie. Rejected. +- **Git-hash + build-date enrichment** (a `build.rs` so dev builds read + `0.2.0 (a1b2c3d)`): useful for bug reports, but not needed for the + tag↔release↔`--version` consistency this ADR is about. Deferred; can be + added behind the same `version_text()` seam without changing the policy. +- **UI placement beyond the `version` command** (status-bar string, etc.): + the command + `help` listing is enough for now (user decision). + +## Consequences + +- A release can no longer ship a binary whose self-reported version + disagrees with its tag/filename. +- Cutting a release now *requires* a `Cargo.toml` bump commit — a small, + deliberate step (and a natural place to update a changelog later). +- New keys: `cli.version_line` (+ `help.app.version`, `parse.usage.version`, + `hint.cmd.version.what`/`.example`); a new REGISTRY command means the + comprehensiveness coverage test now also requires `hint.cmd.version`, + which is supplied. Tested: CLI flag parse (`--version`/`-V`/default-off), + `version_text()` carries `CARGO_PKG_VERSION`, the in-app command parses to + `AppCommand::Version` and emits the version. +- This is step 1 of `docs/plans/20260616-public-availability.md`; the + installer (`install.sh`) and package-manager work (D3) build on top. diff --git a/docs/adr/README.md b/docs/adr/README.md index c831b28..3f042b3 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -66,3 +66,4 @@ This directory contains the project's ADRs, recorded per - [ADR-0051 — Bottom keybinding strip: context- and state-aware](0051-context-state-aware-keybinding-strip.md) — **Accepted + implemented 2026-06-13 (issue #27)**, closes Gitea **#27**. Repurposes the bottom status line into a **keystrokes-only, state-selected** strip (builds on ADR-0046 nav focus, ADR-0003 modes, ADR-0049 the #29 readline keys it now advertises, ADR-0022 the completion memo). A pure `status_bar_bindings(app) -> Vec<(key,label)>` chooses the strip by **priority, first match wins**: (1) **sidebar focus** → `Ctrl-O next pane · ↑↓/PgUp/PgDn scroll · Esc input`; (2) **completion memo live** (`last_completion`) → `Tab/Shift-Tab cycle · Esc cancel · Enter run`; (3) **history navigation** (new `App::is_browsing_history()` exposing the private `history_cursor`) → `↑↓ browse · Esc clear · Enter run`; (4) **editing** (input non-empty) → `Esc clear · Ctrl-A/E home/end · Ctrl-W del word · Enter run` (surfaces the #29 keys, closing ADR-0049's deferred advertisement); (5) **default** (empty) → `Ctrl-O sidebar · Tab complete · ↑ history · Enter run`. Priority is correct because Up clears the completion memo and Tab cancels history nav, so states 2/3 never co-occur, and the five are exhaustive for Input focus. **Typed-command words leave the strip** (`mode advanced`/`mode simple` switch, `:` one-shot) and **mode discovery moves to the empty-input hint** (`resolve_hint_lines`), **simple mode only**: `\`mode advanced\` for SQL` (the verb "type" omitted — the prompt implies it; advanced mode shows **no** pointer per a post-trial user decision — a switcher knows how they got there and `help` covers the way back). The one-shot's old `Backspace cancel one-shot` label is subsumed by the editing state (behaviour intact). Forks all user-chosen: **editing state shows the #29 keys** (vs unadvertised); **`Ctrl-C quit` omitted** from the strip (vs always shown); **no width-drop machinery** — the longest strip (~65 cols) fits all supported widths, so a **width-budget unit test** keeps it lean by construction instead (the user's own observation). Catalog: 12 new `shortcut.*` labels + the `panel.hint_mode_advanced` string added to `en-US.yaml`+`keys.rs` (validator-checked 1:1), 5 now-dead strip strings removed. **Modal-aware strip is OOS** (pre-existing: a modal owns the keyboard and carries its own hints; the strip under it is unchanged-in-kind, not worsened). Tests: 9 Tier-1 unit (per-state key sets — completion/history driven through real key events; width budget; mode-pointer presence/absence), 1 Tier-3 rewritten (`status_bar_is_keystroke_only_and_state_aware`), 15 full-panel snapshots re-accepted (reviewed — strip/hint only). **2467 pass / 0 fail / 0 skip (1 ignored), clippy clean.** OOS: modal-aware strip; a full-key cheatsheet overlay; Ctrl-K/U advertisement (editing strip shows the highest-value subset within the width budget) - [ADR-0052 — Mode-tagged history for cross-mode recall](0052-mode-tagged-history-cross-mode-recall.md) — **Accepted + implemented 2026-06-13 (issue #30)**, closes Gitea **#30** — the feature (advanced history reusable in simple mode) **and** the bug in its comment (the `:` one-shot prefix lost across sessions). **Amends ADR-0034** (status field gains a `:adv` tag; **journaling moves from the worker to the dispatch layer**), **ADR-0015 §5/§6** (history.log leaves the worker transaction — `commit-db-last` now scopes yaml/csv/db only), and **ADR-0040** (a success-path journal-write failure is best-effort, not fatal); references ADR-0003. **Root cause:** history carried no mode, and the in-memory ring stored the raw `:select 1` while the worker journalled the *stripped* `select 1`, so the `:` was lost on disk. **Fix:** record the submission mode per entry as a **`:adv` suffix on the status token** (`ok`/`ok:adv`/`err`/`err:adv`) — `source` stays last + canonical so replay is unaffected; the in-memory ring (still `Vec`) stores advanced entries in their `: `-prefixed simple-mode runnable form (a leading `:` unambiguously marks advanced since simple DSL never starts with `:`); recall **strips the `:` in advanced mode** (runs as bare SQL) and keeps it in simple mode (runs via the one-shot escape); hydration reconstructs the `: `-prefix from the tag, so cross-session = in-session. **The architectural turn (user's call):** the first draft kept journaling in the worker + threaded the mode down (~30-site plumbing); on review the user asked why the journal is written deep in the worker when the *failure* path already journals at the top of the chain — it shouldn't (history.log is a journal, not state). So **success journaling moved up** to `spawn_dsl_dispatch` / `run_replay` / the app-command sites (next to the failure path), the worker's `finalize_persistence` now writes only yaml/csv, and the journal write became **best-effort** (the command is already committed — consistent with the failure path; a rare disk-full leaves a committed command unjournalled, state intact). **App commands** journal simple (dispatched outside the spawn) and `submit` excludes them from the ring's advanced flag, so `undo`/`mode advanced` recall bare. Forks user-chosen: status-tag format (vs 4th field / `:`-in-source); unified scope; **dispatch-layer best-effort journaling** (vs worker-coupled-fatal). Two `/runda` passes (the second drove the relocation + app-command exclusion). Tests: the 15 worker-level journaling tests retired (worker no longer journals — yaml/csv/operation checks kept), re-covered at the new layer (history.rs status-tag + `:`-reconstruct; app.rs recall matrix; the #30 cross-session regression in `iteration6`; replay tests cover `run_replay` journaling). **2471 pass / 0 fail / 0 skip (1 ignored), clippy clean.** replay re-journaling mode-fidelity (a replayed advanced line re-journals simple — not a regression). **Follow-up done 2026-06-14:** the vestigial worker `source` plumbing was fully unwound (compiler-guided, no behaviour change) — `_source` removed from `finalize_persistence`/`do_rebuild_from_text`, the three `*_request` wrappers inlined+deleted, the dead `source` param dropped from the ~30 forwarding worker handlers, and the `source` field removed from the `DescribeTable`/`QueryData`/`RunSelect` requests + their `DatabaseHandle` methods (~164 mostly-test call sites); the only worker `source` left is the snapshot/undo label (see ADR-0052 *Consequences*) - [ADR-0053 — Contextual `hint` command and keybinding](0053-contextual-hint-command-and-keybinding.md) — **Accepted, implemented 2026-06-15** (Phases A–D; closes **A1** + requirements **H2**). Settles the `hint` slot ADR-0003 left "ADR pending"; closes the last open piece of **A1** and tracks requirements **H2**. **Two surfaces:** an **F1 keybinding** that renders a deep hint for the *live* partial input without submitting (the primary path — a submitted `hint` command can't see the buffer it would help with, since Enter empties it), and a submitted **`hint` command** that expands on the *most recent error*. **No topic argument** (contextual only — `help ` already owns explicit reference). Introduces a **tier-3 teaching layer**, deeper than the existing tier-1 (colour / error headline) and tier-2 (ambient one-liner; and the error `hint:`, which is shown **by default** since `Verbosity::Verbose` is the default — `messages short` is the opt-*out*); without it `hint` would just duplicate what's already on screen. Tier-3 content lives in the catalogue under `hint.cmd.` (per command form) and `hint.err.` (per error/diagnostic class), each a structured `what`/`example`/`concept` block rendered via a new `note_hint*` family with `OutputStyleClass::Hint`. **Keyed per-form via a new `hint_ids: &[&str]` field on `CommandNode` mirroring `usage_ids`** (revised in Phase B): a per-*node* key proved too coarse — `add`/`drop`/`show`/`create` are each one node spanning many forms, and a live-input hint for `add 1:n relationship` must be specific to relationships; `hint_key_for_input_in_mode` reuses `usage_key_for_input_in_mode`'s form-word disambiguation, and covers the advanced-SQL forms whose `usage_ids` are empty. Not keyed off `help_id` (it is `None` on the advanced-SQL nodes purely to dedup the `help` list; that parallel gap is issue **#36**). **Clause-concept hints** (`on delete` actions, constraint slots, `with pk`, cardinality) are a recorded **deferred extension** (`hint.concept.`, issue **#37**) — per-form is the right tier-3 granularity, with position-awareness owned by tier-2 + the live `Next:` line. Runtime `translate_error` classes resolve via stored `last_error_hint_key` (`hint` command / empty-F1). (The second route — pre-submit `diagnostic.*` read live from the walker on the F1 path — is **deferred**, issue **#38**: `Diagnostic` carries no class key.) Adds `AppCommand::Hint`, a `HINT` grammar node + REGISTRY entry, the `hint_ids` field, and `last_error_hint_key`; F1 is a read-only overlay (buffer + completion memo untouched). **Content is the bulk of the work** (the mechanism is ~a day): v1 scope = ~37 command forms + 9 runtime error classes (comprehensive for those, ~57 blocks), authored **exemplars-first** (voice approved in this ADR's `/runda` review, then mass-authored in batches), enforced by a **comprehensiveness coverage test**, with graceful fall-back to tier-2 if a key is ever missing. The **pre-submit-diagnostic route + ~33 `diagnostic.*` blocks were deferred** (issue **#38**) — `Diagnostic` carries no class key, so the route needs a broad change for marginal value (tier-2 already surfaces diagnostics; many duplicate runtime classes). Forks user-chosen: two-surface model; **F1** (vs `?` / a chord); no-arg; comprehensive-for-commands-and-errors scope; exemplars-first; diagnostics deferred. OOS: per-topic `hint ` (rejected — overlaps `help`); always-on tier-3 (rejected — keeps ambient terse); non-`en-US` locales + success-command teaching (deferred); clause-concept hints (issue #37); the diagnostic route (issue #38); the `help`-side advanced-SQL gap (issue #36) +- [ADR-0054 — Release versioning policy + version surfaces (`--version` / `version`)](0054-release-versioning-and-version-surfaces.md) — **Accepted + implemented 2026-06-16** (plan: `docs/plans/20260616-public-availability.md`, step 1 on the road to public availability; no prior issue/`requirements.md` item — an untracked gap). Fixes the **tag↔crate-version decoupling**: `Cargo.toml` built `0.1.0` while `release.yaml` named assets from the git tag, so a binary could report a version different from the asset it shipped in. **Decision:** `Cargo.toml` `version` is the **single source of truth** (read via `env!("CARGO_PKG_VERSION")`, no tag-injection); two surfaces report it through one `cli::version_text()` → catalog `cli.version_line` — a **`--version` / `-V`** CLI flag (mirrors `--help`, prints+exits in `main.rs`) and an in-app **`version`** command (REGISTRY node `app::VERSION`, `AppCommand::Version`, emits via `note_system`); and a **release-CI version guard** (`release.yaml` `test` job parses `cargo metadata` and **fails the release** unless the `v*` tag equals `v`). Release ritual: bump `Cargo.toml` → commit → tag → push. New keys `cli.version_line` + `help.app.version` + `parse.usage.version` + `hint.cmd.version.{what,example}` (the new REGISTRY command pulls in the comprehensiveness coverage gate). Rejected: tag-as-source (makes Cargo.toml lie). Deferred: git-hash/build-date enrichment (behind the same `version_text()` seam); UI placement beyond the command. Tested test-first: CLI parse (`--version`/`-V`/default-off), `version_text()` carries `CARGO_PKG_VERSION`, the in-app command parses + emits. Also corrected a stale `release.yaml` header comment ("macOS is deferred" → built by the dispatched `release-macos.yaml`). diff --git a/src/app.rs b/src/app.rs index 1db2815..efba2fa 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1868,6 +1868,12 @@ impl App { self.note_hint_for_recent_error(); Vec::new() } + // ADR-0054: the in-app twin of `--version`. Reports the same + // single source of truth (`CARGO_PKG_VERSION`, via cli::version_text). + AppCommand::Version => { + self.note_system(crate::cli::version_text()); + Vec::new() + } AppCommand::Rebuild => vec![Action::PrepareRebuild], AppCommand::Save => self.handle_save_command(false), AppCommand::SaveAs => self.handle_save_command(true), @@ -5737,6 +5743,29 @@ mod tests { )); } + // ── ADR-0054: in-app `version` command ────────────────────────── + + #[test] + fn version_command_parses_to_app_version() { + use crate::dsl::{parse_command, AppCommand, Command}; + assert!(matches!( + parse_command("version"), + Ok(Command::App(AppCommand::Version)) + )); + } + + #[test] + fn version_command_emits_the_cargo_version() { + let mut app = App::new(); + type_str(&mut app, "version"); + submit(&mut app); + assert!( + output_contains(&app, env!("CARGO_PKG_VERSION")), + "in-app `version` should print CARGO_PKG_VERSION: {}", + error_lines(&app), + ); + } + #[test] fn hint_command_with_no_recent_error_shows_getting_started() { let mut app = App::new(); diff --git a/src/cli.rs b/src/cli.rs index 57f9a23..9796431 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -30,6 +30,10 @@ pub struct Args { /// `--help` / `-h`: print usage to stdout and exit. The /// runtime checks this flag before doing any other work. pub help: bool, + /// `--version` / `-V`: print the version (`CARGO_PKG_VERSION`, + /// the single source of truth — ADR-0054) and exit. Checked + /// alongside `--help` before any other work. + pub version: bool, /// `--no-undo`: disable the auto-snapshot / undo machinery for /// this run (ADR-0006 Amendment 1). When set, no snapshots are /// taken — zero per-command overhead — and `undo` / `redo` @@ -62,6 +66,17 @@ pub fn help_text() -> String { crate::t!("help.cli_banner") } +/// Version line printed by `--version` / `-V` and the in-app `version` +/// command (ADR-0054). +/// +/// `CARGO_PKG_VERSION` is the single source of truth — it equals the `v*` +/// release tag (the release CI guards that), so what the binary reports +/// always matches the downloaded artifact. +#[must_use] +pub fn version_text() -> String { + crate::t!("cli.version_line", version = env!("CARGO_PKG_VERSION")) +} + #[derive(Debug)] pub enum ArgsError { MissingValue(&'static str), @@ -129,6 +144,7 @@ impl Args { let mut project_path: Option = None; let mut resume = false; let mut help = false; + let mut version = false; let mut no_undo = false; let mut mode: Option = None; // Demonstration mode (ADR-0047): the env var is the default, @@ -143,6 +159,9 @@ impl Args { "--help" | "-h" => { help = true; } + "--version" | "-V" => { + version = true; + } "--resume" => { resume = true; } @@ -208,6 +227,7 @@ impl Args { project_path, resume, help, + version, no_undo, mode, demo, @@ -475,4 +495,33 @@ mod tests { let err = Args::parse(["--bogus", "/some/path"]).unwrap_err(); assert!(matches!(&err, ArgsError::Unknown(s) if s == "--bogus"), "got: {err:?}"); } + + // ---- ADR-0054: --version / -V ---- + + #[test] + fn version_long_flag_parses() { + assert!(Args::parse(["--version"]).unwrap().version); + } + + #[test] + fn version_short_flag_parses() { + assert!(Args::parse(["-V"]).unwrap().version); + } + + #[test] + fn version_defaults_off() { + assert!(!Args::parse(std::iter::empty::<&str>()).unwrap().version); + } + + #[test] + fn version_text_carries_the_cargo_version() { + // The binary's self-reported version IS Cargo.toml's (the + // single source of truth, ADR-0054) — and the release CI guards + // that the `v*` tag equals it. + let text = version_text(); + assert!( + text.contains(env!("CARGO_PKG_VERSION")), + "version line should embed CARGO_PKG_VERSION; got {text:?}" + ); + } } diff --git a/src/dsl/command.rs b/src/dsl/command.rs index 66d1933..db0e23e 100644 --- a/src/dsl/command.rs +++ b/src/dsl/command.rs @@ -557,6 +557,10 @@ pub enum AppCommand { /// (the buffer is empty post-submit). The live-input surface is /// the F1 keybinding, handled in `App::handle_key`, not here. Hint, + /// Print the application version (ADR-0054): the in-app twin of the + /// `--version` / `-V` CLI flag. Emits `CARGO_PKG_VERSION` — the same + /// single source of truth — into the output panel. + Version, /// Rebuild `playground.db` from `project.yaml` + data/, with /// confirmation modal. Rebuild, @@ -1019,6 +1023,7 @@ impl Command { AppCommand::Quit => "quit", AppCommand::Help { .. } => "help", AppCommand::Hint => "hint", + AppCommand::Version => "version", AppCommand::Rebuild => "rebuild", AppCommand::Save => "save", AppCommand::SaveAs => "save as", diff --git a/src/dsl/grammar/app.rs b/src/dsl/grammar/app.rs index 814151a..a4f116f 100644 --- a/src/dsl/grammar/app.rs +++ b/src/dsl/grammar/app.rs @@ -174,6 +174,10 @@ const fn build_rebuild(_path: &MatchedPath, _source: &str) -> Result Result { + Ok(Command::App(AppCommand::Version)) +} + const fn build_undo(_path: &MatchedPath, _source: &str) -> Result { Ok(Command::App(AppCommand::Undo)) } @@ -294,6 +298,14 @@ pub static REBUILD: CommandNode = CommandNode { hint_ids: &["rebuild"], usage_ids: &["parse.usage.rebuild"],}; +pub static VERSION: CommandNode = CommandNode { + entry: Word::keyword("version"), + shape: EMPTY_SEQ, + ast_builder: build_version, + help_id: Some("app.version"), + hint_ids: &["version"], + usage_ids: &["parse.usage.version"],}; + pub static SAVE: CommandNode = CommandNode { entry: Word::keyword("save"), shape: SAVE_AS_OPT, diff --git a/src/dsl/grammar/mod.rs b/src/dsl/grammar/mod.rs index 337b039..ff2bc23 100644 --- a/src/dsl/grammar/mod.rs +++ b/src/dsl/grammar/mod.rs @@ -791,6 +791,7 @@ pub static REGISTRY: &[(&CommandNode, CommandCategory)] = &[ (&app::QUIT, CommandCategory::Simple), (&app::HELP, CommandCategory::Simple), (&app::HINT, CommandCategory::Simple), + (&app::VERSION, CommandCategory::Simple), (&app::REBUILD, CommandCategory::Simple), (&app::SAVE, CommandCategory::Simple), (&app::NEW, CommandCategory::Simple), diff --git a/src/friendly/keys.rs b/src/friendly/keys.rs index 341ee54..bd40341 100644 --- a/src/friendly/keys.rs +++ b/src/friendly/keys.rs @@ -181,6 +181,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[ ("help.app.quit", &[]), ("help.app.help", &[]), ("help.app.hint", &[]), + ("help.app.version", &[]), ("help.app.rebuild", &[]), ("help.app.save", &[]), ("help.app.new", &[]), @@ -272,6 +273,8 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[ ("hint.cmd.help.concept", &[]), ("hint.cmd.hint.what", &[]), ("hint.cmd.hint.example", &[]), + ("hint.cmd.version.what", &[]), + ("hint.cmd.version.example", &[]), ("hint.cmd.rebuild.what", &[]), ("hint.cmd.rebuild.example", &[]), ("hint.cmd.rebuild.concept", &[]), @@ -486,6 +489,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[ ("parse.usage.mode", &[]), ("parse.usage.new", &[]), ("parse.usage.quit", &[]), + ("parse.usage.version", &[]), ("parse.usage.rebuild", &[]), ("parse.usage.redo", &[]), ("parse.usage.replay", &[]), @@ -580,6 +584,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[ ("cli.multiple_paths", &["first", "second"]), ("cli.resume_with_path", &[]), ("cli.unknown_argument", &["arg"]), + ("cli.version_line", &["version"]), ( "archive.export_sequence_exhausted", &["project", "target_dir", "limit"], diff --git a/src/friendly/strings/en-US.yaml b/src/friendly/strings/en-US.yaml index 50cc352..41caaf8 100644 --- a/src/friendly/strings/en-US.yaml +++ b/src/friendly/strings/en-US.yaml @@ -162,6 +162,10 @@ error: # ---- Help text (CLI banner + in-app `help` command) ------------------ # ---- CLI argument-parsing errors (stderr before TUI starts) --------- cli: + # Version line for `--version` / `-V` and the in-app `version` command + # (ADR-0054). `{version}` is `CARGO_PKG_VERSION` — the single source of + # truth, equal to the `v*` release tag (release CI guards the match). + version_line: "rdbms-playground {version}" missing_value: "missing value for --{flag}" invalid_value: "invalid value for --{flag}: {value} (expected one of: {expected})" unknown_argument: "unknown argument: {arg}" @@ -186,6 +190,7 @@ help: Options: -h, --help Print this help and exit. + -V, --version Print the version and exit. --theme Override theme (default: auto-detect). --data-dir Use PATH as the data root instead of the OS-standard location for this run. @@ -210,6 +215,7 @@ help: App-level commands (typed inside the app, available in both modes): quit Exit cleanly. + version Print the application version. mode simple|advanced Switch input mode. help Show this list of commands in-app. save Save the current temp project under a @@ -258,6 +264,8 @@ help: help — detailed help for one command (e.g. `help insert`) hint: |- hint — explain the most recent error (press F1 for a hint on what you're typing) + version: |- + version — print the application version (same as the `--version` command-line flag) rebuild: |- rebuild — rebuild the project database from project.yaml + data/ (with confirmation) save: |- @@ -425,6 +433,9 @@ hint: hint: what: "Explain the most recent error — or, pressing F1 while typing, the command you're building." example: "hint" + version: + what: "Print the application version." + example: "version" rebuild: what: "Rebuild the project database from its saved text files." example: "rebuild" @@ -874,6 +885,7 @@ parse: quit: "quit" help: "help []" hint: "hint" + version: "version" rebuild: "rebuild" save: "save | save as" new: "new" diff --git a/src/main.rs b/src/main.rs index dc9c6e2..af54091 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,6 @@ use std::process::ExitCode; -use rdbms_playground::cli::{help_text, Args}; +use rdbms_playground::cli::{help_text, version_text, Args}; use rdbms_playground::{logging, runtime}; fn main() -> ExitCode { @@ -22,6 +22,11 @@ fn main() -> ExitCode { } }; + if args.version { + println!("{}", version_text()); + return ExitCode::SUCCESS; + } + if args.help { print!("{}", help_text()); return ExitCode::SUCCESS; diff --git a/tests/typing_surface/mod.rs b/tests/typing_surface/mod.rs index 31e7229..bf4d88a 100644 --- a/tests/typing_surface/mod.rs +++ b/tests/typing_surface/mod.rs @@ -251,6 +251,7 @@ fn command_kind_label(cmd: &rdbms_playground::dsl::Command) -> String { AppCommand::Quit => "App(Quit)".into(), AppCommand::Help { .. } => "App(Help)".into(), AppCommand::Hint => "App(Hint)".into(), + AppCommand::Version => "App(Version)".into(), AppCommand::Rebuild => "App(Rebuild)".into(), AppCommand::Save => "App(Save)".into(), AppCommand::SaveAs => "App(SaveAs)".into(), From ef99e6c67632d8b91d28db1c4b62e3464380cbcb Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Wed, 17 Jun 2026 19:41:34 +0000 Subject: [PATCH 05/20] feat(install): curl|sh installer script (ADR-0055) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scripts/install.sh — POSIX sh, shellcheck-clean: detects uname OS/arch -> target triple (Linux uses the static musl build; Windows rejected with a Scoop/winget pointer), resolves the latest release (or RDBMS_VERSION), downloads the asset + its .sha256 and verifies it, installs to ~/.local/bin with a PATH hint. RDBMS_OS/RDBMS_ARCH + --print-target are testing seams. Verified end-to-end against the live public v0.1.0 (all mappings, pinned + latest, checksum incl. tamper-rejection, install+run). ADR-0055 + README index; plan-doc step 2 done + decisions recorded (crates.io=yes, releases public, tracking via doc+ADR). --- docs/adr/0055-curl-sh-install-script.md | 75 ++++++++++ docs/adr/README.md | 1 + docs/plans/20260616-public-availability.md | 91 +++++++---- scripts/install.sh | 166 +++++++++++++++++++++ 4 files changed, 305 insertions(+), 28 deletions(-) create mode 100644 docs/adr/0055-curl-sh-install-script.md create mode 100755 scripts/install.sh diff --git a/docs/adr/0055-curl-sh-install-script.md b/docs/adr/0055-curl-sh-install-script.md new file mode 100644 index 0000000..436e3c7 --- /dev/null +++ b/docs/adr/0055-curl-sh-install-script.md @@ -0,0 +1,75 @@ +# ADR-0055: `curl | sh` install script (`scripts/install.sh`) + +## Status + +Accepted — **implemented 2026-06-17** (plan: +`docs/plans/20260616-public-availability.md`, step 2). Step 2 on the road +to public availability, building on ADR-0054 (versioned releases) and +ADR-ci-001/003 (the Gitea releases it downloads from). Tracked by the +plan + this ADR (no Gitea issue — user decision, 2026-06-17). + +## Context + +Until now, installing meant: find the releases page, work out which of +the six assets matches your machine, download it, `chmod +x`, move it +onto `PATH`, and (on macOS) wonder about Gatekeeper. That is too much +friction for a teaching tool aimed at beginners. The Gitea releases are +**publicly downloadable** (confirmed), with deterministic asset names +(`rdbms-playground--[.exe]`) and `.sha256` sidecars +(ADR-ci-003), and a `releases/latest` API — enough to script a one-liner +install. + +## Decision + +Ship **`scripts/install.sh`**, run as +`curl -fsSL /scripts/install.sh | sh`: + +- **POSIX `sh`** (no bashisms) — it runs under the `sh` of `curl | sh`; + kept **shellcheck-clean** (`-s sh`). +- **Platform detection** from `uname` → target triple: Linux → + `-unknown-linux-musl` (the fully-static build — one universal + Linux artifact, no glibc/version coupling), macOS → `-apple-darwin`; + `x86_64`/`amd64` and `aarch64`/`arm64` both map. **Windows is rejected** + with a pointer to Scoop/winget/the releases page (the binary is a `.exe`, + not a `curl|sh` target). +- **Version:** the `releases/latest` API tag by default; `RDBMS_VERSION` + pins a specific tag. +- **Integrity:** always download the `.sha256` sidecar and **verify** + (`sha256sum`/`shasum -a 256`); a mismatch aborts the install. HTTPS only. +- **Install location:** `~/.local/bin` by default (user-writable, no + sudo), overridable via `RDBMS_INSTALL_DIR`; prints a PATH hint if the + dir isn't on `PATH`. +- **macOS note:** a `curl` download is **not** Gatekeeper-quarantined, so + the binary runs as-is even while it is only ad-hoc-signed; proper + Developer-ID signing + notarization (for *browser* downloads) is a + separate, postponed task (see the plan's signing item). +- **Testing seams:** `RDBMS_OS`/`RDBMS_ARCH` force detection and + `--print-target` prints the resolved triple and exits — so the mapping + is checkable without a download. + +### Rejected / deferred +- **Hosting the script on the website domain** (Cloudflare): nicer URL, + but adds a moving part; the **Gitea repo raw URL** is simplest and the + binaries live there anyway (user decision). The website may later + *reference* the same command. +- **`install.ps1` (Windows):** deferred — Windows users go via Scoop / + winget (D3, §3). +- **Uploading `install.sh` as a release asset** for a stable link: + optional; the branch raw URL is fine for now. + +## Consequences + +- A first-time user runs one line and gets a checksum-verified binary on + `PATH`. The website's install copy (website branch, separate agent) can + point at this command. +- **Verified end-to-end** (2026-06-17) against the live public `v0.1.0`: + all four Linux/macOS platform mappings + Windows/unknown-arch rejection; + pinned and latest paths; checksum verification incl. a tamper-rejection + check; install + run on Linux x86_64. (The installed `v0.1.0` predates + `--version`, ADR-0054 — a non-issue, and the reason to cut a new + release.) +- **No automated regression guard in CI yet:** shellcheck isn't in the + flake, and there's no shell-test harness here (no bats). Recommended + follow-up: add a `shellcheck scripts/*.sh` gate (touches ADR-ci-002 — + needs shellcheck in the devShell). For now the guard is local + shellcheck + the documented end-to-end verification. diff --git a/docs/adr/README.md b/docs/adr/README.md index 3f042b3..6539a94 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -67,3 +67,4 @@ This directory contains the project's ADRs, recorded per - [ADR-0052 — Mode-tagged history for cross-mode recall](0052-mode-tagged-history-cross-mode-recall.md) — **Accepted + implemented 2026-06-13 (issue #30)**, closes Gitea **#30** — the feature (advanced history reusable in simple mode) **and** the bug in its comment (the `:` one-shot prefix lost across sessions). **Amends ADR-0034** (status field gains a `:adv` tag; **journaling moves from the worker to the dispatch layer**), **ADR-0015 §5/§6** (history.log leaves the worker transaction — `commit-db-last` now scopes yaml/csv/db only), and **ADR-0040** (a success-path journal-write failure is best-effort, not fatal); references ADR-0003. **Root cause:** history carried no mode, and the in-memory ring stored the raw `:select 1` while the worker journalled the *stripped* `select 1`, so the `:` was lost on disk. **Fix:** record the submission mode per entry as a **`:adv` suffix on the status token** (`ok`/`ok:adv`/`err`/`err:adv`) — `source` stays last + canonical so replay is unaffected; the in-memory ring (still `Vec`) stores advanced entries in their `: `-prefixed simple-mode runnable form (a leading `:` unambiguously marks advanced since simple DSL never starts with `:`); recall **strips the `:` in advanced mode** (runs as bare SQL) and keeps it in simple mode (runs via the one-shot escape); hydration reconstructs the `: `-prefix from the tag, so cross-session = in-session. **The architectural turn (user's call):** the first draft kept journaling in the worker + threaded the mode down (~30-site plumbing); on review the user asked why the journal is written deep in the worker when the *failure* path already journals at the top of the chain — it shouldn't (history.log is a journal, not state). So **success journaling moved up** to `spawn_dsl_dispatch` / `run_replay` / the app-command sites (next to the failure path), the worker's `finalize_persistence` now writes only yaml/csv, and the journal write became **best-effort** (the command is already committed — consistent with the failure path; a rare disk-full leaves a committed command unjournalled, state intact). **App commands** journal simple (dispatched outside the spawn) and `submit` excludes them from the ring's advanced flag, so `undo`/`mode advanced` recall bare. Forks user-chosen: status-tag format (vs 4th field / `:`-in-source); unified scope; **dispatch-layer best-effort journaling** (vs worker-coupled-fatal). Two `/runda` passes (the second drove the relocation + app-command exclusion). Tests: the 15 worker-level journaling tests retired (worker no longer journals — yaml/csv/operation checks kept), re-covered at the new layer (history.rs status-tag + `:`-reconstruct; app.rs recall matrix; the #30 cross-session regression in `iteration6`; replay tests cover `run_replay` journaling). **2471 pass / 0 fail / 0 skip (1 ignored), clippy clean.** replay re-journaling mode-fidelity (a replayed advanced line re-journals simple — not a regression). **Follow-up done 2026-06-14:** the vestigial worker `source` plumbing was fully unwound (compiler-guided, no behaviour change) — `_source` removed from `finalize_persistence`/`do_rebuild_from_text`, the three `*_request` wrappers inlined+deleted, the dead `source` param dropped from the ~30 forwarding worker handlers, and the `source` field removed from the `DescribeTable`/`QueryData`/`RunSelect` requests + their `DatabaseHandle` methods (~164 mostly-test call sites); the only worker `source` left is the snapshot/undo label (see ADR-0052 *Consequences*) - [ADR-0053 — Contextual `hint` command and keybinding](0053-contextual-hint-command-and-keybinding.md) — **Accepted, implemented 2026-06-15** (Phases A–D; closes **A1** + requirements **H2**). Settles the `hint` slot ADR-0003 left "ADR pending"; closes the last open piece of **A1** and tracks requirements **H2**. **Two surfaces:** an **F1 keybinding** that renders a deep hint for the *live* partial input without submitting (the primary path — a submitted `hint` command can't see the buffer it would help with, since Enter empties it), and a submitted **`hint` command** that expands on the *most recent error*. **No topic argument** (contextual only — `help ` already owns explicit reference). Introduces a **tier-3 teaching layer**, deeper than the existing tier-1 (colour / error headline) and tier-2 (ambient one-liner; and the error `hint:`, which is shown **by default** since `Verbosity::Verbose` is the default — `messages short` is the opt-*out*); without it `hint` would just duplicate what's already on screen. Tier-3 content lives in the catalogue under `hint.cmd.` (per command form) and `hint.err.` (per error/diagnostic class), each a structured `what`/`example`/`concept` block rendered via a new `note_hint*` family with `OutputStyleClass::Hint`. **Keyed per-form via a new `hint_ids: &[&str]` field on `CommandNode` mirroring `usage_ids`** (revised in Phase B): a per-*node* key proved too coarse — `add`/`drop`/`show`/`create` are each one node spanning many forms, and a live-input hint for `add 1:n relationship` must be specific to relationships; `hint_key_for_input_in_mode` reuses `usage_key_for_input_in_mode`'s form-word disambiguation, and covers the advanced-SQL forms whose `usage_ids` are empty. Not keyed off `help_id` (it is `None` on the advanced-SQL nodes purely to dedup the `help` list; that parallel gap is issue **#36**). **Clause-concept hints** (`on delete` actions, constraint slots, `with pk`, cardinality) are a recorded **deferred extension** (`hint.concept.`, issue **#37**) — per-form is the right tier-3 granularity, with position-awareness owned by tier-2 + the live `Next:` line. Runtime `translate_error` classes resolve via stored `last_error_hint_key` (`hint` command / empty-F1). (The second route — pre-submit `diagnostic.*` read live from the walker on the F1 path — is **deferred**, issue **#38**: `Diagnostic` carries no class key.) Adds `AppCommand::Hint`, a `HINT` grammar node + REGISTRY entry, the `hint_ids` field, and `last_error_hint_key`; F1 is a read-only overlay (buffer + completion memo untouched). **Content is the bulk of the work** (the mechanism is ~a day): v1 scope = ~37 command forms + 9 runtime error classes (comprehensive for those, ~57 blocks), authored **exemplars-first** (voice approved in this ADR's `/runda` review, then mass-authored in batches), enforced by a **comprehensiveness coverage test**, with graceful fall-back to tier-2 if a key is ever missing. The **pre-submit-diagnostic route + ~33 `diagnostic.*` blocks were deferred** (issue **#38**) — `Diagnostic` carries no class key, so the route needs a broad change for marginal value (tier-2 already surfaces diagnostics; many duplicate runtime classes). Forks user-chosen: two-surface model; **F1** (vs `?` / a chord); no-arg; comprehensive-for-commands-and-errors scope; exemplars-first; diagnostics deferred. OOS: per-topic `hint ` (rejected — overlaps `help`); always-on tier-3 (rejected — keeps ambient terse); non-`en-US` locales + success-command teaching (deferred); clause-concept hints (issue #37); the diagnostic route (issue #38); the `help`-side advanced-SQL gap (issue #36) - [ADR-0054 — Release versioning policy + version surfaces (`--version` / `version`)](0054-release-versioning-and-version-surfaces.md) — **Accepted + implemented 2026-06-16** (plan: `docs/plans/20260616-public-availability.md`, step 1 on the road to public availability; no prior issue/`requirements.md` item — an untracked gap). Fixes the **tag↔crate-version decoupling**: `Cargo.toml` built `0.1.0` while `release.yaml` named assets from the git tag, so a binary could report a version different from the asset it shipped in. **Decision:** `Cargo.toml` `version` is the **single source of truth** (read via `env!("CARGO_PKG_VERSION")`, no tag-injection); two surfaces report it through one `cli::version_text()` → catalog `cli.version_line` — a **`--version` / `-V`** CLI flag (mirrors `--help`, prints+exits in `main.rs`) and an in-app **`version`** command (REGISTRY node `app::VERSION`, `AppCommand::Version`, emits via `note_system`); and a **release-CI version guard** (`release.yaml` `test` job parses `cargo metadata` and **fails the release** unless the `v*` tag equals `v`). Release ritual: bump `Cargo.toml` → commit → tag → push. New keys `cli.version_line` + `help.app.version` + `parse.usage.version` + `hint.cmd.version.{what,example}` (the new REGISTRY command pulls in the comprehensiveness coverage gate). Rejected: tag-as-source (makes Cargo.toml lie). Deferred: git-hash/build-date enrichment (behind the same `version_text()` seam); UI placement beyond the command. Tested test-first: CLI parse (`--version`/`-V`/default-off), `version_text()` carries `CARGO_PKG_VERSION`, the in-app command parses + emits. Also corrected a stale `release.yaml` header comment ("macOS is deferred" → built by the dispatched `release-macos.yaml`). +- [ADR-0055 — `curl | sh` install script (`scripts/install.sh`)](0055-curl-sh-install-script.md) — **Accepted + implemented 2026-06-17** (plan: `docs/plans/20260616-public-availability.md`, step 2; tracked by plan + ADR, no Gitea issue — user decision). A one-line installer (`curl -fsSL /scripts/install.sh | sh`) so beginners don't hand-pick an asset + `chmod +x`. **POSIX `sh`** (shellcheck-clean), detects `uname` OS/arch → target triple (**Linux → the fully-static `*-musl`** build, macOS → `*-apple-darwin`; `amd64`/`arm64` aliased; **Windows rejected** → Scoop/winget/releases page), resolves the version from the **`releases/latest`** API (or `RDBMS_VERSION` to pin), downloads the asset **and its `.sha256` and verifies it** (mismatch aborts), installs to `~/.local/bin` (`RDBMS_INSTALL_DIR` override) with a PATH hint. Testing seams: `RDBMS_OS`/`RDBMS_ARCH` + `--print-target`. macOS note: `curl` downloads aren't Gatekeeper-quarantined so the ad-hoc binary runs as-is (Developer-ID + notarization is the postponed signing task). **Verified end-to-end against the live public `v0.1.0`** (all platform mappings, pinned + latest, checksum incl. tamper-rejection, install + run). Rejected: website-domain hosting (extra moving part; Gitea raw is simplest); deferred: `install.ps1`, uploading the script as a release asset, and a **shellcheck CI gate** (shellcheck isn't in the flake — touches ADR-ci-002). diff --git a/docs/plans/20260616-public-availability.md b/docs/plans/20260616-public-availability.md index c9e6dbb..704d0c7 100644 --- a/docs/plans/20260616-public-availability.md +++ b/docs/plans/20260616-public-availability.md @@ -32,7 +32,20 @@ anything user-facing (taps, buckets). --- -## 2. `install.sh` (curl | sh) — DECIDED shape +## 2. `install.sh` (curl | sh) — DONE 2026-06-17 (ADR-0055) + +**Shipped** `scripts/install.sh` (POSIX sh, shellcheck-clean). Verified +end-to-end against the live public `v0.1.0` release: platform mappings +(Linux/macOS × x86_64/aarch64; Windows + unknown arch error cleanly), +pinned (`RDBMS_VERSION`) and latest (`releases/latest`) paths, SHA-256 +verification (incl. a tamper-rejection check), install to +`~/.local/bin`, PATH hint. **`install.ps1` (Windows) deferred** — Windows +users go via Scoop/winget (§3). The website copy that references the +`curl` command is the **website branch's** job (separate agent), later. +A **shellcheck CI gate** for `scripts/` is a recommended follow-up (not +added — shellcheck isn't in the flake yet; touches ADR-ci-002). + +Original decided shape (for reference): - **Hosted from the Gitea repo URL** on `git.lazyeval.net` (simplest): `curl -fsSL https://git.lazyeval.net/oli/rdbms-playground/raw/branch/main/scripts/install.sh | sh` @@ -72,11 +85,13 @@ per-release step to bump it. Ordered cheapest → most gatekept. `cargo install cargo-binstall`). **Our instructions must say this.** - Add `[package.metadata.binstall]` to `Cargo.toml` (pkg-url template → our Gitea release assets; our naming already fits). -- **OPEN:** the frictionless `cargo binstall rdbms-playground` resolves - the crate via **crates.io**. Decision needed: **publish to crates.io?** - If not, document the `cargo binstall --git ` form instead. - *(Verify current cargo-binstall non-crates.io behaviour before - committing wording.)* +- **DECIDED (2026-06-17): publish to crates.io** — so the frictionless + `cargo binstall rdbms-playground` resolves the crate, and the project + is discoverable there. (A crates.io publish is its own small task: + metadata completeness — description/license/repository/keywords/readme + — and `cargo publish`; the `[package.metadata.binstall]` URL template + points binstall at our Gitea release assets.) *(Verify current + cargo-binstall behaviour when wiring.)* ### 3b. Scoop (Windows) - A **bucket** repo under `lazyeval` on Gitea with a JSON manifest @@ -113,29 +128,49 @@ per-release step to bump it. Ordered cheapest → most gatekept. --- -## Open decisions (need the user) +## Open decisions -1. **crates.io:** publish the crate? (changes the `cargo binstall` + - discoverability story). -2. **macOS signing — CONFIRMED BUG (2026-06-16).** `release-macos.yaml` - line 52 runs `codesign --force --sign -` (the `-` = **ad-hoc**), so a - downloaded binary is *not* properly signed — user-verified. The Apple - **Developer ID** identity was provisioned in the runner VM's keychain - (the CI agent asked for it) but the workflow never references it, so - it sits **unused**. Fix = a `release-macos.yaml` change: sign with - `codesign --sign "Developer ID Application: "` - against the keychain identity, **after** the `install_name_tool` - de-nix (which invalidates any signature), then ideally **notarize + - staple** (needs Apple notarytool creds — Apple ID + app-specific - password, or an App Store Connect API key — as runner secrets). A - distinct task from the version work; corrects the docs once landed. -3. **Issue tracking:** file Gitea issues for these (version; install.sh; - D3), or track via this plan + ADR only? +1. **crates.io:** **RESOLVED 2026-06-17 — yes, publish.** (See §3a.) +2. **Tracking:** **RESOLVED 2026-06-17 — doc + ADR only, no Gitea + issues.** +3. **Release downloads public:** **CONFIRMED 2026-06-17** — the Gitea + releases are publicly downloadable (no auth); `install.sh` relies on + it and was verified against the live `v0.1.0`. + +### Still open / postponed + +- **macOS signing — CONFIRMED BUG (2026-06-16), POSTPONED by the user + (2026-06-17)** pending the correct signing ID. Details: + - `release-macos.yaml` does `codesign --force --sign -` (ad-hoc) and has + **no signing scaffolding at all** (no keychain import, no secrets) — + so a downloaded binary is *not* properly signed (user-verified). + - **The credential the user has is the wrong type:** `Apple Development: + Oliver Sturm (W687M898E4)` is a *development* cert (Gatekeeper won't + trust it for distribution). Distribution needs a **`Developer ID + Application`** cert (same format, different type). Signing under the + company name *"Lazy Evaluation Ltd"* would need an **Organization** + Apple Developer account; a personal account signs as "Oliver Sturm". + - **Notarization** (required with Developer ID for non-quarantined trust + on browser downloads): after signing, `xcrun notarytool submit`. Creds + = an **App Store Connect API key** (Issuer ID + Key ID + `.p8`, + recommended for CI) *or* Apple ID + app-specific password + Team ID. + A bare CLI binary can't be *stapled* (only bundles/dmg/pkg) — Gatekeeper + does an online check instead. + - **Urgency caveat:** the `curl|sh` path doesn't need any of this (curl + downloads aren't quarantined); signing matters for browser downloads + from the releases page. Fix when the right cert + creds exist; corrects + the ad-hoc docs once landed. ## Sequencing -1. **Version discipline** (ADR-0054) — `--version`/`-V` + `version` - command + CI tag-match guard + tests. **← building now.** -2. `scripts/install.sh` + confirm public download URLs. -3. Package managers, cheapest first: `cargo binstall` + Scoop → Homebrew - → winget. +1. ✅ **Version discipline** (ADR-0054) — `--version`/`-V` + `version` + command + CI tag-match guard + tests. +2. ✅ **`scripts/install.sh`** (ADR-0055) — built + verified against the + live public release. +3. **← next:** package managers, cheapest first: `cargo binstall` + (+ crates.io publish) + Scoop → Homebrew (`lazyeval` tap) → winget + (komac / manual). Two `lazyeval` repos (tap + bucket) + CI push creds + to set up. +4. **Cut a release at a new version** — bump `Cargo.toml` (0.1.0 → + 0.1.1/0.2.0; the ADR-0054 guard checks the tag), tag, push; the four + Linux/Windows targets build immediately. (macOS leg awaits signing.) diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..5609f50 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,166 @@ +#!/bin/sh +# install.sh — download and install a prebuilt rdbms-playground binary. +# +# Quick start (Linux / macOS): +# curl -fsSL https://git.lazyeval.net/oli/rdbms-playground/raw/branch/main/scripts/install.sh | sh +# +# What it does: detects your OS + CPU, downloads the matching release binary +# from the Gitea releases (Linux uses the fully-static musl build), verifies +# its SHA-256 checksum, and installs it to ~/.local/bin. +# +# Environment overrides: +# RDBMS_VERSION install a specific tag (e.g. v0.2.0) instead of the latest +# RDBMS_INSTALL_DIR install directory (default: $HOME/.local/bin) +# RDBMS_OS force the OS (testing): Linux | Darwin +# RDBMS_ARCH force the CPU (testing): x86_64 | aarch64 +# +# Flags: +# --print-target print the resolved target triple and exit (no download) +# -h, --help print this help and exit +# +# Notes: +# * Windows is not installable via this script (the binary is a .exe) — +# use Scoop/winget (planned) or download the .exe from the releases page. +# * macOS: a curl download is not quarantined by Gatekeeper, so the binary +# runs without extra steps. (Developer-ID signing + notarization is a +# separate, planned improvement for browser downloads.) +# +# POSIX sh — no bashisms, so it runs under the `sh` of `curl | sh`. + +set -eu + +REPO_BASE="https://git.lazyeval.net/oli/rdbms-playground" +API_BASE="https://git.lazyeval.net/api/v1/repos/oli/rdbms-playground" +BIN_NAME="rdbms-playground" +PRINT_TARGET=0 + +err() { + printf 'install: %s\n' "$1" >&2 + exit 1 +} +info() { printf '%s\n' "$1" >&2; } + +usage() { + # Lines 2..(first blank) of this file are the human-readable header. + sed -n '2,/^$/p' "$0" | sed 's/^# \{0,1\}//' +} + +# Resolve the Rust target triple for the current (or forced) platform. +# Linux -> -unknown-linux-musl (the fully-static build) +# macOS -> -apple-darwin +detect_target() { + os="${RDBMS_OS:-$(uname -s)}" + arch="${RDBMS_ARCH:-$(uname -m)}" + case "$os" in + Linux | linux) os_part="unknown-linux-musl" ;; + Darwin | darwin | macos | macOS) os_part="apple-darwin" ;; + MINGW* | MSYS* | CYGWIN* | *Windows* | *windows*) + err "Windows is not supported by this installer — use Scoop/winget (planned) or download the .exe from $REPO_BASE/releases" ;; + *) err "unsupported operating system: $os" ;; + esac + case "$arch" in + x86_64 | amd64) arch_part="x86_64" ;; + aarch64 | arm64) arch_part="aarch64" ;; + *) err "unsupported CPU architecture: $arch" ;; + esac + printf '%s-%s' "$arch_part" "$os_part" +} + +# Download $1 to file $2 (curl or wget). +download() { + if command -v curl >/dev/null 2>&1; then + curl -fsSL "$1" -o "$2" + elif command -v wget >/dev/null 2>&1; then + wget -qO "$2" "$1" + else + err "need either curl or wget on PATH" + fi +} + +# Fetch $1 to stdout (curl or wget). +fetch() { + if command -v curl >/dev/null 2>&1; then + curl -fsSL "$1" + elif command -v wget >/dev/null 2>&1; then + wget -qO- "$1" + else + err "need either curl or wget on PATH" + fi +} + +# The release tag to install: $RDBMS_VERSION if set, else the latest release. +resolve_version() { + if [ -n "${RDBMS_VERSION:-}" ]; then + printf '%s' "$RDBMS_VERSION" + return + fi + json=$(fetch "$API_BASE/releases/latest") || + err "could not query the latest release from $API_BASE" + # Portable JSON scrape (no jq): the latest-release object carries exactly + # one "tag_name": "" field. + tag=$(printf '%s' "$json" | + grep -o '"tag_name"[[:space:]]*:[[:space:]]*"[^"]*"' | + head -1 | sed 's/.*"\([^"]*\)"$/\1/') + [ -n "$tag" ] || err "could not parse the latest release tag" + printf '%s' "$tag" +} + +# Verify file $1 against sha256 sidecar $2 (format: " "). +verify_checksum() { + expected=$(awk '{print $1; exit}' "$2") + if command -v sha256sum >/dev/null 2>&1; then + actual=$(sha256sum "$1" | awk '{print $1}') + elif command -v shasum >/dev/null 2>&1; then + actual=$(shasum -a 256 "$1" | awk '{print $1}') + else + info "warning: no sha256 tool found — skipping checksum verification" + return 0 + fi + [ "$expected" = "$actual" ] || + err "checksum mismatch (expected $expected, got $actual) — refusing to install" +} + +main() { + while [ $# -gt 0 ]; do + case "$1" in + --print-target) PRINT_TARGET=1 ;; + -h | --help) + usage + exit 0 + ;; + *) err "unknown argument: $1 (try --help)" ;; + esac + shift + done + + target=$(detect_target) + if [ "$PRINT_TARGET" = "1" ]; then + printf '%s\n' "$target" + exit 0 + fi + + version=$(resolve_version) + asset="$BIN_NAME-$version-$target" + url="$REPO_BASE/releases/download/$version/$asset" + dir="${RDBMS_INSTALL_DIR:-$HOME/.local/bin}" + + tmp=$(mktemp -d 2>/dev/null) || err "could not create a temporary directory" + trap 'rm -rf "$tmp"' EXIT INT TERM + + info "downloading $asset ..." + download "$url" "$tmp/$BIN_NAME" || err "download failed: $url" + download "$url.sha256" "$tmp/$BIN_NAME.sha256" || err "checksum download failed: $url.sha256" + verify_checksum "$tmp/$BIN_NAME" "$tmp/$BIN_NAME.sha256" + + mkdir -p "$dir" || err "could not create install directory: $dir" + chmod +x "$tmp/$BIN_NAME" + mv "$tmp/$BIN_NAME" "$dir/$BIN_NAME" || err "could not install to $dir" + info "installed $BIN_NAME $version -> $dir/$BIN_NAME" + + case ":${PATH:-}:" in + *":$dir:"*) ;; + *) info "note: $dir is not on your PATH. Add it, e.g.: export PATH=\"$dir:\$PATH\"" ;; + esac +} + +main "$@" From e9606b5f6d596d3394881aa3b8e5619f53f4a018 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Wed, 17 Jun 2026 21:25:45 +0000 Subject: [PATCH 06/20] feat(dist): crates.io + binstall + Windows install.ps1 + license files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Distribution prep on the road to public availability (plan steps 2–3a). - Cargo.toml: publish-ready (drop publish=false; homepage/keywords/ categories/exclude) + [package.metadata.binstall] with per-target overrides (linux-gnu->musl, windows-msvc->gnu/gnullvm). dry-run clean. - scripts/install.ps1: Windows `irm | iex` one-liner — written but untested here (no PowerShell; validate on Windows). README Windows block. - README.md (new); LICENSE-MIT + LICENSE-APACHE (dual, (c) Lazy Evaluation Ltd); CONTRIBUTING.md (inbound=outbound dual-license note). - ADR-0055 Amendment 1 (install.ps1), ADR-0056 (crates.io/binstall), README index + plan updates. The actual `cargo publish` remains a gated maintainer step (token, irreversible) at a new tagged release; real cargo-binstall validation pending. --- CONTRIBUTING.md | 20 ++ Cargo.toml | 41 +++- LICENSE-APACHE | 202 ++++++++++++++++++ LICENSE-MIT | 21 ++ README.md | 98 +++++++++ docs/adr/0055-curl-sh-install-script.md | 15 +- docs/adr/0056-crates-io-and-cargo-binstall.md | 88 ++++++++ docs/adr/README.md | 3 +- docs/plans/20260616-public-availability.md | 33 ++- scripts/install.ps1 | 94 ++++++++ 10 files changed, 603 insertions(+), 12 deletions(-) create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE-APACHE create mode 100644 LICENSE-MIT create mode 100644 README.md create mode 100644 docs/adr/0056-crates-io-and-cargo-binstall.md create mode 100644 scripts/install.ps1 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..a94ee0c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,20 @@ +# Contributing to rdbms-playground + +Contributions are welcome — bug reports, ideas, and pull requests. The +project lives on Gitea at +; please file issues and +open pull requests there. It's approaching its first public release, so +the most useful contributions right now are bug reports and rough edges +you hit while learning. + +## License of contributions + +Unless you explicitly state otherwise, any contribution you intentionally +submit for inclusion in this project — as defined in the Apache-2.0 +license — shall be **dual-licensed under `MIT OR Apache-2.0`** (the +project's licenses), without any additional terms or conditions. + +This is the standard Rust "inbound = outbound" arrangement: your +contribution is offered under the same licenses the project distributes +under, so — via Apache-2.0 §5 — it carries the Apache-2.0 §3 patent grant +to all users. No separate CLA is required. diff --git a/Cargo.toml b/Cargo.toml index 32408ec..92aa8f6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,8 +5,17 @@ edition = "2024" description = "A cross-platform TUI playground for learning relational databases." license = "MIT OR Apache-2.0" repository = "https://git.lazyeval.net/oli/rdbms-playground" +homepage = "https://relplay.org" readme = "README.md" -publish = false +keywords = ["database", "sql", "tui", "learning", "playground"] +categories = ["command-line-utilities", "database"] +# Keep the published crate to the code that builds the binary — the website, +# decision records, and CI plumbing are repo-only (ADR-0056). +exclude = ["/website", "/docs", "/.gitea", "/.codegraph"] +# `publish = false` removed (ADR-0056): the crate is intended for +# crates.io. The actual `cargo publish` is a deliberate, irreversible +# maintainer step (needs the crates.io token) — do it at a tagged release +# whose assets the binstall metadata below points at. [dependencies] anyhow = "1.0.102" @@ -85,3 +94,33 @@ nursery = { level = "warn", priority = -1 } module_name_repetitions = "allow" missing_errors_doc = "allow" missing_panics_doc = "allow" + +# cargo-binstall (ADR-0056): let `cargo binstall rdbms-playground` fetch the +# prebuilt release binary instead of compiling from source. Our release assets +# are BARE binaries (no archive) named `rdbms-playground-v-` +# (`.exe` on Windows) with `.sha256` sidecars (ADR-ci-003), so `pkg-fmt = "bin"`. +# `{ version }` excludes the leading `v`, so the template spells `v{ version }`. +# +# Target mapping: macOS host triples match our asset triples directly. But we +# ship the fully-static *-linux-MUSL build (glibc hosts are *-linux-gnu) and +# *-windows-GNU/GNULLVM (most Windows hosts are *-msvc), so those common host +# triples need explicit overrides pointing at the asset we actually publish. +# +# NOTE: unverified against a real `cargo binstall` run (binstall isn't a dep and +# nothing is on crates.io yet) — validate at the first publish + matching release. +[package.metadata.binstall] +pkg-url = "{ repo }/releases/download/v{ version }/{ name }-v{ version }-{ target }{ archive-suffix }" +pkg-fmt = "bin" +bin-dir = "{ bin }{ binary-ext }" + +[package.metadata.binstall.overrides.x86_64-unknown-linux-gnu] +pkg-url = "{ repo }/releases/download/v{ version }/{ name }-v{ version }-x86_64-unknown-linux-musl" + +[package.metadata.binstall.overrides.aarch64-unknown-linux-gnu] +pkg-url = "{ repo }/releases/download/v{ version }/{ name }-v{ version }-aarch64-unknown-linux-musl" + +[package.metadata.binstall.overrides.x86_64-pc-windows-msvc] +pkg-url = "{ repo }/releases/download/v{ version }/{ name }-v{ version }-x86_64-pc-windows-gnu.exe" + +[package.metadata.binstall.overrides.aarch64-pc-windows-msvc] +pkg-url = "{ repo }/releases/download/v{ version }/{ name }-v{ version }-aarch64-pc-windows-gnullvm.exe" diff --git a/LICENSE-APACHE b/LICENSE-APACHE new file mode 100644 index 0000000..a57cdc8 --- /dev/null +++ b/LICENSE-APACHE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 Lazy Evaluation Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/LICENSE-MIT b/LICENSE-MIT new file mode 100644 index 0000000..cf46631 --- /dev/null +++ b/LICENSE-MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Lazy Evaluation Ltd + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..37c7550 --- /dev/null +++ b/README.md @@ -0,0 +1,98 @@ +# rdbms-playground + +A cross-platform terminal app for **learning relational database +concepts** — tables, columns, primary and foreign keys, relationships, +indexes, queries, and query plans — in a safe sandbox. + +It's a teaching tool, not a database administration tool. It meets +beginners with guided, keyword-based commands (**simple mode**) and grows +with them to raw SQL (**advanced mode**), so the same playground works +from "what is a primary key?" through to writing real queries and reading +their execution plans. + +Website & documentation: **** + +## Install + +### One line (Linux / macOS) + +```sh +curl -fsSL https://git.lazyeval.net/oli/rdbms-playground/raw/branch/main/scripts/install.sh | sh +``` + +Detects your OS and CPU, downloads the matching release binary, verifies +its SHA-256 checksum, and installs it to `~/.local/bin`. Set +`RDBMS_INSTALL_DIR` to install elsewhere, or `RDBMS_VERSION=vX.Y.Z` to +pin a version. (Prefer to read before you run? The script lives at +`scripts/install.sh`.) + +### One line (Windows, PowerShell) + +```powershell +irm https://git.lazyeval.net/oli/rdbms-playground/raw/branch/main/scripts/install.ps1 | iex +``` + +Downloads the matching `.exe`, verifies its checksum, installs to +`%LOCALAPPDATA%\Programs\rdbms-playground`, and adds it to your user +PATH. Or use a package manager (Scoop / winget) once those land. + +### With `cargo binstall` + +If you have [`cargo-binstall`](https://github.com/cargo-bins/cargo-binstall) +(install it first — it is not part of `cargo` itself): + +```sh +cargo binstall rdbms-playground +``` + +### From source + +```sh +cargo install rdbms-playground # from crates.io +# or, from a clone: +cargo build --release # binary at target/release/rdbms-playground +``` + +### Prebuilt binaries + +Every release publishes static Linux, standalone Windows, and macOS +binaries (x86_64 and aarch64) with `.sha256` checksums on the +[releases page](https://git.lazyeval.net/oli/rdbms-playground/releases). +Windows users can also use the binary directly (package-manager support +is planned). + +## A quick taste + +``` +create table Customers with pk id(serial) +add column Customers: name (text) +add column Customers: email (text) +insert into Customers values ('Ann', 'ann@example.io') +show data Customers +``` + +Press **F1** while typing for a contextual hint about the command you're +building, or type `help` for the full command list. Switch to raw SQL +with `mode advanced` (or prefix a single line with `:`). + +## Project status + +Approaching its first public release. See the website for current +features; installation via package managers (Homebrew, Scoop, winget) is +on the roadmap. + +## License + +Dual-licensed under either of + +- MIT license ([LICENSE-MIT](LICENSE-MIT)) +- Apache License 2.0 ([LICENSE-APACHE](LICENSE-APACHE)) + +at your option. + +## Contribution + +Unless you explicitly state otherwise, any contribution intentionally +submitted for inclusion in the work by you, as defined in the Apache-2.0 +license, shall be dual-licensed as above, without any additional terms or +conditions. See [CONTRIBUTING.md](CONTRIBUTING.md). diff --git a/docs/adr/0055-curl-sh-install-script.md b/docs/adr/0055-curl-sh-install-script.md index 436e3c7..bc447a1 100644 --- a/docs/adr/0055-curl-sh-install-script.md +++ b/docs/adr/0055-curl-sh-install-script.md @@ -52,11 +52,22 @@ Ship **`scripts/install.sh`**, run as but adds a moving part; the **Gitea repo raw URL** is simplest and the binaries live there anyway (user decision). The website may later *reference* the same command. -- **`install.ps1` (Windows):** deferred — Windows users go via Scoop / - winget (D3, §3). - **Uploading `install.sh` as a release asset** for a stable link: optional; the branch raw URL is fine for now. +## Amendment 1 — `install.ps1` (Windows) added (2026-06-17) + +Windows was originally deferred to Scoop/winget; the user opted for **both** +a PowerShell one-liner now *and* package managers later. Added +**`scripts/install.ps1`** (`irm | iex`): maps the host CPU to our +`*-windows-gnu`/`-gnullvm` `.exe`, resolves the latest release (or +`-Version`/`RDBMS_VERSION`), downloads + **SHA-256-verifies**, installs to +`%LOCALAPPDATA%\Programs\rdbms-playground` (`-InstallDir`/`RDBMS_INSTALL_DIR` +override), and adds that dir to the **user PATH**. **Caveat:** unlike +`install.sh` (verified end-to-end), this was **written but not tested from +this environment** (no PowerShell available) — validate on a real Windows +host. Scoop/winget (D3) remain the idiomatic package-manager routes. + ## Consequences - A first-time user runs one line and gets a checksum-verified binary on diff --git a/docs/adr/0056-crates-io-and-cargo-binstall.md b/docs/adr/0056-crates-io-and-cargo-binstall.md new file mode 100644 index 0000000..a10d8ed --- /dev/null +++ b/docs/adr/0056-crates-io-and-cargo-binstall.md @@ -0,0 +1,88 @@ +# ADR-0056: crates.io publish-readiness + `cargo binstall` metadata (D3) + +## Status + +Accepted — **prepared 2026-06-17** (plan: +`docs/plans/20260616-public-availability.md`, step 3a). The crate is made +**ready to publish** and carries `cargo-binstall` metadata. The actual +`cargo publish` is a gated maintainer step (see Ordering). First D3 +package-manager mechanism; builds on ADR-0054 (versioned releases), +ADR-0055 (installer), ADR-ci-003 (release assets). Tracked by plan + ADR +(no Gitea issue — user decision). + +## Context + +`cargo binstall rdbms-playground` (and `cargo install`) need the crate on +**crates.io** (user decision, 2026-06-17). The manifest had +`publish = false`, a `readme = "README.md"` pointing at a **missing** +file, and no `keywords`/`categories`. Our release assets are **bare +binaries** (not archives) named `rdbms-playground-v-` +(`.exe` on Windows) with `.sha256` sidecars (ADR-ci-003); critically the +**release target triples differ from users' host triples** — we ship the +static `*-linux-musl` build (hosts are `*-linux-gnu`) and +`*-windows-gnu`/`-gnullvm` (hosts are `*-msvc`); only macOS matches. + +## Decision + +**Publish-readiness (this change):** +- Drop `publish = false`; add `homepage = "https://relplay.org"`, + `keywords`, `categories = ["command-line-utilities", "database"]`, and + an `exclude` (`/website`, `/docs`, `/.gitea`, `/.codegraph`) so the + published crate is code-only (585 files/8.3 MiB → 353/913 KiB + compressed). +- Author **`README.md`** (the `readme` target + crates.io front page; + engine-neutral and "simple/advanced mode" wording per ADR-0002 / the + website copy rules), with install instructions (curl|sh, binstall, + source, prebuilt). +- Add **`LICENSE-MIT`** and **`LICENSE-APACHE`** (the latter the verbatim + canonical text, added by the maintainer; both © Lazy Evaluation Ltd — + the publication entity), and a **`CONTRIBUTING.md`** stating the + "inbound = outbound" dual-license arrangement (so Apache-2.0 §5 makes + the §3 patent grant explicit on the self-hosted forge). Dual license + kept (not MIT-only) — user decision after reviewing the patent-grant + rationale. + +**`cargo binstall` metadata** (`[package.metadata.binstall]`, syntax +verified against cargo-binstall SUPPORT.md): +- `pkg-fmt = "bin"` (bare binary), `bin-dir = "{ bin }{ binary-ext }"`, + and a base `pkg-url` using `v{ version }` (the `{ version }` placeholder + excludes the leading `v`). +- **Per-target `overrides`** mapping the common host triples to the asset + we actually publish: `x86_64`/`aarch64-unknown-linux-gnu` → the `-musl` + asset; `x86_64`/`aarch64-pc-windows-msvc` → the `-gnu`/`-gnullvm` + `.exe`. macOS needs no override (host triple == asset triple). The docs + do **not** promise automatic musl/gnu or msvc/gnu fallback, hence + explicit overrides. + +**Ordering / gating (important):** +- `cargo publish` is **irreversible** (needs the crates.io token; a + version can't be un-published, only yanked) — a deliberate **maintainer + step**, not done here. +- binstall's `pkg-url` resolves to a **tagged release's** assets, so + publish **at a new tagged version whose release already exists**, and + publish **after** that release is built. **Do not publish `0.1.0`** — it + would diverge from the already-released `0.1.0` binaries (which predate + `--version`, ADR-0054). The clean path: bump → tag → release builds → + `cargo publish`. + +## Verification + +- `cargo publish --dry-run --allow-dirty` packages + verify-builds cleanly + (353 files, 913 KiB compressed; no metadata errors). +- `cargo metadata` confirms the `binstall` block + all four `overrides` + parse. +- **Unverified:** an actual `cargo binstall` run — cargo-binstall isn't a + dependency and nothing is on crates.io yet. **Validate at the first + publish + matching release** (especially the windows-msvc→gnu and + linux-gnu→musl overrides). + +## Consequences + +- The crate can be published at the next tagged release with `cargo + publish` (+ the token); `cargo install rdbms-playground` and `cargo + binstall rdbms-playground` then work. +- Remaining D3: Scoop, Homebrew (`lazyeval` tap), winget (komac/manual) — + each a manifest + a per-release bump, tracked in the plan. +- Remaining follow-up: run the real `cargo binstall` validation at the + first publish + matching release (the license files, © holder, and + CONTRIBUTING are now in place). diff --git a/docs/adr/README.md b/docs/adr/README.md index 6539a94..213c4de 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -67,4 +67,5 @@ This directory contains the project's ADRs, recorded per - [ADR-0052 — Mode-tagged history for cross-mode recall](0052-mode-tagged-history-cross-mode-recall.md) — **Accepted + implemented 2026-06-13 (issue #30)**, closes Gitea **#30** — the feature (advanced history reusable in simple mode) **and** the bug in its comment (the `:` one-shot prefix lost across sessions). **Amends ADR-0034** (status field gains a `:adv` tag; **journaling moves from the worker to the dispatch layer**), **ADR-0015 §5/§6** (history.log leaves the worker transaction — `commit-db-last` now scopes yaml/csv/db only), and **ADR-0040** (a success-path journal-write failure is best-effort, not fatal); references ADR-0003. **Root cause:** history carried no mode, and the in-memory ring stored the raw `:select 1` while the worker journalled the *stripped* `select 1`, so the `:` was lost on disk. **Fix:** record the submission mode per entry as a **`:adv` suffix on the status token** (`ok`/`ok:adv`/`err`/`err:adv`) — `source` stays last + canonical so replay is unaffected; the in-memory ring (still `Vec`) stores advanced entries in their `: `-prefixed simple-mode runnable form (a leading `:` unambiguously marks advanced since simple DSL never starts with `:`); recall **strips the `:` in advanced mode** (runs as bare SQL) and keeps it in simple mode (runs via the one-shot escape); hydration reconstructs the `: `-prefix from the tag, so cross-session = in-session. **The architectural turn (user's call):** the first draft kept journaling in the worker + threaded the mode down (~30-site plumbing); on review the user asked why the journal is written deep in the worker when the *failure* path already journals at the top of the chain — it shouldn't (history.log is a journal, not state). So **success journaling moved up** to `spawn_dsl_dispatch` / `run_replay` / the app-command sites (next to the failure path), the worker's `finalize_persistence` now writes only yaml/csv, and the journal write became **best-effort** (the command is already committed — consistent with the failure path; a rare disk-full leaves a committed command unjournalled, state intact). **App commands** journal simple (dispatched outside the spawn) and `submit` excludes them from the ring's advanced flag, so `undo`/`mode advanced` recall bare. Forks user-chosen: status-tag format (vs 4th field / `:`-in-source); unified scope; **dispatch-layer best-effort journaling** (vs worker-coupled-fatal). Two `/runda` passes (the second drove the relocation + app-command exclusion). Tests: the 15 worker-level journaling tests retired (worker no longer journals — yaml/csv/operation checks kept), re-covered at the new layer (history.rs status-tag + `:`-reconstruct; app.rs recall matrix; the #30 cross-session regression in `iteration6`; replay tests cover `run_replay` journaling). **2471 pass / 0 fail / 0 skip (1 ignored), clippy clean.** replay re-journaling mode-fidelity (a replayed advanced line re-journals simple — not a regression). **Follow-up done 2026-06-14:** the vestigial worker `source` plumbing was fully unwound (compiler-guided, no behaviour change) — `_source` removed from `finalize_persistence`/`do_rebuild_from_text`, the three `*_request` wrappers inlined+deleted, the dead `source` param dropped from the ~30 forwarding worker handlers, and the `source` field removed from the `DescribeTable`/`QueryData`/`RunSelect` requests + their `DatabaseHandle` methods (~164 mostly-test call sites); the only worker `source` left is the snapshot/undo label (see ADR-0052 *Consequences*) - [ADR-0053 — Contextual `hint` command and keybinding](0053-contextual-hint-command-and-keybinding.md) — **Accepted, implemented 2026-06-15** (Phases A–D; closes **A1** + requirements **H2**). Settles the `hint` slot ADR-0003 left "ADR pending"; closes the last open piece of **A1** and tracks requirements **H2**. **Two surfaces:** an **F1 keybinding** that renders a deep hint for the *live* partial input without submitting (the primary path — a submitted `hint` command can't see the buffer it would help with, since Enter empties it), and a submitted **`hint` command** that expands on the *most recent error*. **No topic argument** (contextual only — `help ` already owns explicit reference). Introduces a **tier-3 teaching layer**, deeper than the existing tier-1 (colour / error headline) and tier-2 (ambient one-liner; and the error `hint:`, which is shown **by default** since `Verbosity::Verbose` is the default — `messages short` is the opt-*out*); without it `hint` would just duplicate what's already on screen. Tier-3 content lives in the catalogue under `hint.cmd.` (per command form) and `hint.err.` (per error/diagnostic class), each a structured `what`/`example`/`concept` block rendered via a new `note_hint*` family with `OutputStyleClass::Hint`. **Keyed per-form via a new `hint_ids: &[&str]` field on `CommandNode` mirroring `usage_ids`** (revised in Phase B): a per-*node* key proved too coarse — `add`/`drop`/`show`/`create` are each one node spanning many forms, and a live-input hint for `add 1:n relationship` must be specific to relationships; `hint_key_for_input_in_mode` reuses `usage_key_for_input_in_mode`'s form-word disambiguation, and covers the advanced-SQL forms whose `usage_ids` are empty. Not keyed off `help_id` (it is `None` on the advanced-SQL nodes purely to dedup the `help` list; that parallel gap is issue **#36**). **Clause-concept hints** (`on delete` actions, constraint slots, `with pk`, cardinality) are a recorded **deferred extension** (`hint.concept.`, issue **#37**) — per-form is the right tier-3 granularity, with position-awareness owned by tier-2 + the live `Next:` line. Runtime `translate_error` classes resolve via stored `last_error_hint_key` (`hint` command / empty-F1). (The second route — pre-submit `diagnostic.*` read live from the walker on the F1 path — is **deferred**, issue **#38**: `Diagnostic` carries no class key.) Adds `AppCommand::Hint`, a `HINT` grammar node + REGISTRY entry, the `hint_ids` field, and `last_error_hint_key`; F1 is a read-only overlay (buffer + completion memo untouched). **Content is the bulk of the work** (the mechanism is ~a day): v1 scope = ~37 command forms + 9 runtime error classes (comprehensive for those, ~57 blocks), authored **exemplars-first** (voice approved in this ADR's `/runda` review, then mass-authored in batches), enforced by a **comprehensiveness coverage test**, with graceful fall-back to tier-2 if a key is ever missing. The **pre-submit-diagnostic route + ~33 `diagnostic.*` blocks were deferred** (issue **#38**) — `Diagnostic` carries no class key, so the route needs a broad change for marginal value (tier-2 already surfaces diagnostics; many duplicate runtime classes). Forks user-chosen: two-surface model; **F1** (vs `?` / a chord); no-arg; comprehensive-for-commands-and-errors scope; exemplars-first; diagnostics deferred. OOS: per-topic `hint ` (rejected — overlaps `help`); always-on tier-3 (rejected — keeps ambient terse); non-`en-US` locales + success-command teaching (deferred); clause-concept hints (issue #37); the diagnostic route (issue #38); the `help`-side advanced-SQL gap (issue #36) - [ADR-0054 — Release versioning policy + version surfaces (`--version` / `version`)](0054-release-versioning-and-version-surfaces.md) — **Accepted + implemented 2026-06-16** (plan: `docs/plans/20260616-public-availability.md`, step 1 on the road to public availability; no prior issue/`requirements.md` item — an untracked gap). Fixes the **tag↔crate-version decoupling**: `Cargo.toml` built `0.1.0` while `release.yaml` named assets from the git tag, so a binary could report a version different from the asset it shipped in. **Decision:** `Cargo.toml` `version` is the **single source of truth** (read via `env!("CARGO_PKG_VERSION")`, no tag-injection); two surfaces report it through one `cli::version_text()` → catalog `cli.version_line` — a **`--version` / `-V`** CLI flag (mirrors `--help`, prints+exits in `main.rs`) and an in-app **`version`** command (REGISTRY node `app::VERSION`, `AppCommand::Version`, emits via `note_system`); and a **release-CI version guard** (`release.yaml` `test` job parses `cargo metadata` and **fails the release** unless the `v*` tag equals `v`). Release ritual: bump `Cargo.toml` → commit → tag → push. New keys `cli.version_line` + `help.app.version` + `parse.usage.version` + `hint.cmd.version.{what,example}` (the new REGISTRY command pulls in the comprehensiveness coverage gate). Rejected: tag-as-source (makes Cargo.toml lie). Deferred: git-hash/build-date enrichment (behind the same `version_text()` seam); UI placement beyond the command. Tested test-first: CLI parse (`--version`/`-V`/default-off), `version_text()` carries `CARGO_PKG_VERSION`, the in-app command parses + emits. Also corrected a stale `release.yaml` header comment ("macOS is deferred" → built by the dispatched `release-macos.yaml`). -- [ADR-0055 — `curl | sh` install script (`scripts/install.sh`)](0055-curl-sh-install-script.md) — **Accepted + implemented 2026-06-17** (plan: `docs/plans/20260616-public-availability.md`, step 2; tracked by plan + ADR, no Gitea issue — user decision). A one-line installer (`curl -fsSL /scripts/install.sh | sh`) so beginners don't hand-pick an asset + `chmod +x`. **POSIX `sh`** (shellcheck-clean), detects `uname` OS/arch → target triple (**Linux → the fully-static `*-musl`** build, macOS → `*-apple-darwin`; `amd64`/`arm64` aliased; **Windows rejected** → Scoop/winget/releases page), resolves the version from the **`releases/latest`** API (or `RDBMS_VERSION` to pin), downloads the asset **and its `.sha256` and verifies it** (mismatch aborts), installs to `~/.local/bin` (`RDBMS_INSTALL_DIR` override) with a PATH hint. Testing seams: `RDBMS_OS`/`RDBMS_ARCH` + `--print-target`. macOS note: `curl` downloads aren't Gatekeeper-quarantined so the ad-hoc binary runs as-is (Developer-ID + notarization is the postponed signing task). **Verified end-to-end against the live public `v0.1.0`** (all platform mappings, pinned + latest, checksum incl. tamper-rejection, install + run). Rejected: website-domain hosting (extra moving part; Gitea raw is simplest); deferred: `install.ps1`, uploading the script as a release asset, and a **shellcheck CI gate** (shellcheck isn't in the flake — touches ADR-ci-002). +- [ADR-0055 — `curl | sh` install script (`scripts/install.sh`)](0055-curl-sh-install-script.md) — **Accepted + implemented 2026-06-17** (plan: `docs/plans/20260616-public-availability.md`, step 2; tracked by plan + ADR, no Gitea issue — user decision). A one-line installer (`curl -fsSL /scripts/install.sh | sh`) so beginners don't hand-pick an asset + `chmod +x`. **POSIX `sh`** (shellcheck-clean), detects `uname` OS/arch → target triple (**Linux → the fully-static `*-musl`** build, macOS → `*-apple-darwin`; `amd64`/`arm64` aliased; **Windows rejected** → Scoop/winget/releases page), resolves the version from the **`releases/latest`** API (or `RDBMS_VERSION` to pin), downloads the asset **and its `.sha256` and verifies it** (mismatch aborts), installs to `~/.local/bin` (`RDBMS_INSTALL_DIR` override) with a PATH hint. Testing seams: `RDBMS_OS`/`RDBMS_ARCH` + `--print-target`. macOS note: `curl` downloads aren't Gatekeeper-quarantined so the ad-hoc binary runs as-is (Developer-ID + notarization is the postponed signing task). **Verified end-to-end against the live public `v0.1.0`** (all platform mappings, pinned + latest, checksum incl. tamper-rejection, install + run). Rejected: website-domain hosting (extra moving part; Gitea raw is simplest); deferred: uploading the script as a release asset, and a **shellcheck CI gate** (shellcheck isn't in the flake — touches ADR-ci-002). **Amendment 1 (2026-06-17):** added a Windows **`scripts/install.ps1`** (`irm | iex`; maps host CPU → our `*-windows-gnu`/`-gnullvm` `.exe`, SHA-256-verifies, installs to `%LOCALAPPDATA%\Programs\…` + user PATH) — user chose both a one-liner *and* Scoop/winget; **written but untested from this env** (no PowerShell — validate on Windows). +- [ADR-0056 — crates.io publish-readiness + `cargo binstall` metadata (D3)](0056-crates-io-and-cargo-binstall.md) — **Prepared 2026-06-17** (plan step 3a; tracked by plan + ADR). Makes the crate **ready to publish** to crates.io (user decision) and adds `cargo-binstall` metadata; the actual `cargo publish` is a **gated, irreversible maintainer step**. Manifest: drops `publish = false`; adds `homepage` (relplay.org), `keywords`, `categories`, and an `exclude` (`/website`,`/docs`,`/.gitea`,`/.codegraph`) trimming the crate from 585 files/8.3 MiB → **353/913 KiB compressed** (code-only). Authors **`README.md`** (engine-neutral, simple/advanced-mode wording; install via curl|sh/binstall/source/prebuilt) and **`LICENSE-MIT`** (© Lazy Evaluation Ltd — *confirm holder*); the canonical **`LICENSE-APACHE`** is deferred to the maintainer (don't ship retyped legal text) — the SPDX `license` field already satisfies crates.io. **binstall** (syntax verified vs cargo-binstall SUPPORT.md): `pkg-fmt = "bin"` (bare binaries), `pkg-url` spelled `v{ version }` (the placeholder omits the `v`), plus per-target **`overrides`** mapping the common host triples to the assets we ship — `*-linux-gnu` → the static `*-linux-musl` build, `*-pc-windows-msvc` → `*-gnu`/`-gnullvm` `.exe` (macOS matches directly; the docs promise no automatic fallback). **Ordering:** publish at a **new tagged version whose release exists**, after the release — **not `0.1.0`** (diverges from the already-released 0.1.0 binaries that predate `--version`). Verified: `cargo publish --dry-run` packages + verify-builds; `cargo metadata` confirms the binstall block + 4 overrides. **Unverified:** a real `cargo binstall` run (not a dep; nothing on crates.io yet) — validate at first publish. Rejected: cargo-dist (GitHub-centric). Maintainer follow-ups: confirm © holder, add canonical `LICENSE-APACHE`, real binstall validation. diff --git a/docs/plans/20260616-public-availability.md b/docs/plans/20260616-public-availability.md index 704d0c7..b5b9c25 100644 --- a/docs/plans/20260616-public-availability.md +++ b/docs/plans/20260616-public-availability.md @@ -39,9 +39,13 @@ end-to-end against the live public `v0.1.0` release: platform mappings (Linux/macOS × x86_64/aarch64; Windows + unknown arch error cleanly), pinned (`RDBMS_VERSION`) and latest (`releases/latest`) paths, SHA-256 verification (incl. a tamper-rejection check), install to -`~/.local/bin`, PATH hint. **`install.ps1` (Windows) deferred** — Windows -users go via Scoop/winget (§3). The website copy that references the -`curl` command is the **website branch's** job (separate agent), later. +`~/.local/bin`, PATH hint. **`install.ps1` (Windows) added 2026-06-17** +(user chose both a one-liner *and* Scoop/winget; ADR-0055 Amendment 1): +`irm | iex`, maps host CPU → our `*-windows-gnu`/`-gnullvm` `.exe`, +SHA-256-verifies, installs to `%LOCALAPPDATA%\Programs\…` + user PATH — +**written but untested from this env** (no PowerShell; validate on +Windows). The website copy that references these commands is the +**website branch's** job (separate agent), later. A **shellcheck CI gate** for `scripts/` is a recommended follow-up (not added — shellcheck isn't in the flake yet; touches ADR-ci-002). @@ -78,7 +82,18 @@ Original decided shape (for reference): Common thread: a manifest pointing at our checksummed assets + a per-release step to bump it. Ordered cheapest → most gatekept. -### 3a. `cargo binstall` +### 3a. `cargo binstall` + crates.io — PREPARED 2026-06-17 (ADR-0056) + +**Done (prep):** crate made publish-ready (dropped `publish = false`; +added `homepage`/`keywords`/`categories`/`exclude`; authored `README.md` ++ `LICENSE-MIT`); `[package.metadata.binstall]` added with per-target +overrides (linux-gnu→musl, windows-msvc→gnu/gnullvm; macOS direct); +`cargo publish --dry-run` clean (913 KiB compressed). Dual license kept; +`LICENSE-MIT` + `LICENSE-APACHE` (© Lazy Evaluation Ltd) + `CONTRIBUTING.md` +(inbound=outbound) all in place. **Gated / remaining:** the actual `cargo +publish` (token, irreversible) at a **new tagged release** (not 0.1.0); a +real `cargo binstall` validation. + - **Bootstrapping matters (user-flagged):** `binstall` is **not** a built-in cargo subcommand — users must install **`cargo-binstall`** first (its own `curl|sh`/PowerShell installer, or @@ -167,10 +182,12 @@ per-release step to bump it. Ordered cheapest → most gatekept. command + CI tag-match guard + tests. 2. ✅ **`scripts/install.sh`** (ADR-0055) — built + verified against the live public release. -3. **← next:** package managers, cheapest first: `cargo binstall` - (+ crates.io publish) + Scoop → Homebrew (`lazyeval` tap) → winget - (komac / manual). Two `lazyeval` repos (tap + bucket) + CI push creds - to set up. +3. Package managers, cheapest first: + - ✅ **`cargo binstall` + crates.io** — *prepared* (ADR-0056); + publish gated on a new tagged release + the token. + - **← next:** Scoop (`lazyeval` bucket) → Homebrew (`lazyeval` tap) → + winget (komac / manual). Two `lazyeval` repos (tap + bucket) + CI + push creds to set up. 4. **Cut a release at a new version** — bump `Cargo.toml` (0.1.0 → 0.1.1/0.2.0; the ADR-0054 guard checks the tag), tag, push; the four Linux/Windows targets build immediately. (macOS leg awaits signing.) diff --git a/scripts/install.ps1 b/scripts/install.ps1 new file mode 100644 index 0000000..b2ffe2c --- /dev/null +++ b/scripts/install.ps1 @@ -0,0 +1,94 @@ +<# +.SYNOPSIS + Download and install a prebuilt rdbms-playground binary (Windows). + +.DESCRIPTION + The Windows counterpart of scripts/install.sh. Detects the CPU + architecture, downloads the matching release .exe from the Gitea + releases, verifies its SHA-256 checksum, installs it to + %LOCALAPPDATA%\Programs\rdbms-playground, and adds that directory to + your user PATH. + + Quick start: + irm https://git.lazyeval.net/oli/rdbms-playground/raw/branch/main/scripts/install.ps1 | iex + + We ship gnu / gnullvm Windows builds (x86_64 / aarch64); this maps the + host architecture to the right asset. + +.PARAMETER Version + Install a specific tag (e.g. v0.2.0) instead of the latest release. + Defaults to $env:RDBMS_VERSION, else the latest release. + +.PARAMETER InstallDir + Install directory. Defaults to $env:RDBMS_INSTALL_DIR, else + %LOCALAPPDATA%\Programs\rdbms-playground. + +.NOTES + Written but NOT tested on Windows from this environment (no PowerShell + here) — validate on a real Windows host. The verified sibling is + install.sh (Linux/macOS). +#> +[CmdletBinding()] +param( + [string]$Version = $env:RDBMS_VERSION, + [string]$InstallDir = $(if ($env:RDBMS_INSTALL_DIR) { $env:RDBMS_INSTALL_DIR } else { "$env:LOCALAPPDATA\Programs\rdbms-playground" }) +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$Repo = 'https://git.lazyeval.net/oli/rdbms-playground' +$Api = 'https://git.lazyeval.net/api/v1/repos/oli/rdbms-playground' +$Bin = 'rdbms-playground' + +# Map the host CPU to the target triple we publish for Windows. +$osArch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture +switch ($osArch) { + 'X64' { $target = 'x86_64-pc-windows-gnu' } + 'Arm64' { $target = 'aarch64-pc-windows-gnullvm' } + default { throw "install: unsupported CPU architecture: $osArch" } +} + +# Resolve the release tag (explicit -Version, else the latest release). +if (-not $Version) { + $Version = (Invoke-RestMethod -Uri "$Api/releases/latest").tag_name + if (-not $Version) { throw 'install: could not determine the latest release tag' } +} + +$asset = "$Bin-$Version-$target.exe" +$url = "$Repo/releases/download/$Version/$asset" + +$tmp = Join-Path $env:TEMP ([System.Guid]::NewGuid().ToString()) +New-Item -ItemType Directory -Path $tmp -Force | Out-Null +try { + $exe = Join-Path $tmp "$Bin.exe" + $shaFile = "$exe.sha256" + + Write-Host "downloading $asset ..." + Invoke-WebRequest -Uri $url -OutFile $exe + Invoke-WebRequest -Uri "$url.sha256" -OutFile $shaFile + + # The sidecar is " "; compare just the hash. + $expected = ((Get-Content -Raw $shaFile) -split '\s+')[0].ToLower() + $actual = (Get-FileHash -Algorithm SHA256 -Path $exe).Hash.ToLower() + if ($expected -ne $actual) { + throw "install: checksum mismatch (expected $expected, got $actual) — refusing to install" + } + + New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null + $dest = Join-Path $InstallDir "$Bin.exe" + Move-Item -Path $exe -Destination $dest -Force + Write-Host "installed $Bin $Version -> $dest" + + # Add the install dir to the user PATH (persistent) if it's not there. + $userPath = [Environment]::GetEnvironmentVariable('Path', 'User') + if (-not $userPath) { $userPath = '' } + if (($userPath -split ';') -notcontains $InstallDir) { + $newPath = if ($userPath) { "$userPath;$InstallDir" } else { $InstallDir } + [Environment]::SetEnvironmentVariable('Path', $newPath, 'User') + Write-Host "added $InstallDir to your user PATH — restart your shell to pick it up" + } +} +finally { + Remove-Item -Path $tmp -Recurse -Force -ErrorAction SilentlyContinue +} From 41b7e9a04992cd9708f1775b57044de838b48b85 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Wed, 17 Jun 2026 21:39:19 +0000 Subject: [PATCH 07/20] style: format the whole tree with cargo fmt (stock defaults, #35) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One-time, mechanical reformat — no functional changes. The tree was not rustfmt-clean (~1800 hunks across ~100 files); this brings it to stock `cargo fmt` defaults so a `cargo fmt --check` CI gate can follow. Behaviour-preserving: 2509 pass / 0 fail / 1 ignored (unchanged baseline), clippy clean. A .git-blame-ignore-revs entry follows so `git blame` skips this commit. --- src/app.rs | 398 ++-- src/archive.rs | 74 +- src/cli.rs | 68 +- src/completion.rs | 464 +++-- src/db.rs | 1936 +++++++++++------- src/dsl/command.rs | 21 +- src/dsl/grammar/app.rs | 71 +- src/dsl/grammar/data.rs | 179 +- src/dsl/grammar/ddl.rs | 412 ++-- src/dsl/grammar/expr.rs | 59 +- src/dsl/grammar/mod.rs | 44 +- src/dsl/grammar/shared.rs | 41 +- src/dsl/grammar/sql_create_table.rs | 94 +- src/dsl/grammar/sql_delete.rs | 14 +- src/dsl/grammar/sql_expr.rs | 94 +- src/dsl/grammar/sql_insert.rs | 39 +- src/dsl/grammar/sql_select.rs | 159 +- src/dsl/grammar/sql_update.rs | 14 +- src/dsl/mod.rs | 6 +- src/dsl/parser.rs | 43 +- src/dsl/shortid.rs | 3 +- src/dsl/sql_functions.rs | 30 +- src/dsl/types.rs | 11 +- src/dsl/value.rs | 55 +- src/dsl/walker/driver.rs | 298 ++- src/dsl/walker/highlight.rs | 69 +- src/dsl/walker/lex_helpers.rs | 4 +- src/dsl/walker/mod.rs | 989 ++++----- src/echo.rs | 148 +- src/event.rs | 15 +- src/friendly/format.rs | 14 +- src/friendly/keys.rs | 53 +- src/friendly/mod.rs | 4 +- src/friendly/translate.rs | 155 +- src/input_render.rs | 282 ++- src/logging.rs | 7 +- src/main.rs | 2 +- src/output_render.rs | 111 +- src/persistence/csv_io.rs | 42 +- src/persistence/history.rs | 51 +- src/persistence/migrations.rs | 80 +- src/persistence/mod.rs | 38 +- src/persistence/yaml.rs | 152 +- src/project/lock.rs | 10 +- src/project/mod.rs | 57 +- src/project/naming.rs | 45 +- src/project/prettifier.rs | 10 +- src/runtime.rs | 274 ++- src/seed/check.rs | 10 +- src/seed/generators.rs | 169 +- src/seed/heuristics.rs | 225 +- src/seed/mod.rs | 7 +- src/theme.rs | 67 +- src/type_change.rs | 39 +- src/ui.rs | 373 ++-- src/undo.rs | 18 +- tests/it/case_insensitive_names.rs | 60 +- tests/it/column_op_guards.rs | 30 +- tests/it/compound_fk.rs | 79 +- tests/it/engine_vocabulary_audit.rs | 13 +- tests/it/friendly_enrichment.rs | 84 +- tests/it/help_command.rs | 2 +- tests/it/iteration2_persistence.rs | 33 +- tests/it/iteration3_rebuild.rs | 65 +- tests/it/iteration4a_rebuild_command.rs | 16 +- tests/it/iteration4b_lifecycle_commands.rs | 9 +- tests/it/iteration5_export_import.rs | 18 +- tests/it/iteration6_resume_history.rs | 3 +- tests/it/m2n.rs | 210 +- tests/it/main.rs | 2 +- tests/it/parse_error_pedagogy.rs | 540 ++++- tests/it/project_lifecycle.rs | 33 +- tests/it/replay_command.rs | 113 +- tests/it/seed.rs | 469 ++++- tests/it/show_list.rs | 70 +- tests/it/sql_alter_table.rs | 256 ++- tests/it/sql_create_index.rs | 62 +- tests/it/sql_create_table.rs | 609 ++++-- tests/it/sql_delete.rs | 325 ++- tests/it/sql_dml_e2e.rs | 296 ++- tests/it/sql_drop_index.rs | 31 +- tests/it/sql_drop_table.rs | 85 +- tests/it/sql_insert.rs | 523 ++++- tests/it/sql_select.rs | 98 +- tests/it/sql_update.rs | 247 ++- tests/it/undo_snapshots.rs | 14 +- tests/it/walking_skeleton.rs | 79 +- tests/typing_surface/add_relationship.rs | 20 +- tests/typing_surface/create_m2n.rs | 5 +- tests/typing_surface/create_table.rs | 15 +- tests/typing_surface/delete_with_where.rs | 18 +- tests/typing_surface/drop_column.rs | 5 +- tests/typing_surface/drop_relationship.rs | 15 +- tests/typing_surface/explain.rs | 10 +- tests/typing_surface/insert_form_a.rs | 44 +- tests/typing_surface/insert_form_b.rs | 76 +- tests/typing_surface/insert_form_c.rs | 35 +- tests/typing_surface/mod.rs | 51 +- tests/typing_surface/rename_change_column.rs | 15 +- tests/typing_surface/update_all_rows.rs | 20 +- tests/typing_surface/update_with_where.rs | 37 +- tests/typing_surface/where_expression.rs | 5 +- 102 files changed, 8017 insertions(+), 4975 deletions(-) diff --git a/src/app.rs b/src/app.rs index efba2fa..ad721b8 100644 --- a/src/app.rs +++ b/src/app.rs @@ -509,7 +509,10 @@ pub enum LoadPickerSubMode { /// Switched to via `b`. Same input/cursor surface as /// `PathEntryModal`; kept inline so the picker can flip /// back to List with `Esc`. - PathEntry { input: String, cursor: usize }, + PathEntry { + input: String, + cursor: usize, + }, } const PAGE_SCROLL_LINES: usize = 5; @@ -697,9 +700,7 @@ impl App { // `trimmed[1..].trim()`. let leading_ws = self.input.len() - self.input.trim_start().len(); let mut offset = leading_ws + 1; // past the `:` - while offset < self.input.len() - && self.input.as_bytes()[offset].is_ascii_whitespace() - { + while offset < self.input.len() && self.input.as_bytes()[offset].is_ascii_whitespace() { offset += 1; } let view = &self.input[offset..]; @@ -727,8 +728,7 @@ impl App { pub fn input_validity_verdict(&self) -> Option { let mode = match self.effective_mode() { EffectiveMode::Simple => Mode::Simple, - EffectiveMode::AdvancedPersistent - | EffectiveMode::AdvancedOneShot => Mode::Advanced, + EffectiveMode::AdvancedPersistent | EffectiveMode::AdvancedOneShot => Mode::Advanced, }; // Strip the `:` one-shot prefix so the walker verdicts the SQL // itself, not the escape marker (which it can't parse). @@ -1037,10 +1037,7 @@ impl App { Vec::new() } AppEvent::ExportSucceeded { path } => { - self.note_system(crate::t!( - "project.export_ok", - path = path.display() - )); + self.note_system(crate::t!("project.export_ok", path = path.display())); Vec::new() } AppEvent::ExportFailed { error } => { @@ -1056,11 +1053,7 @@ impl App { // `[ok] replay — N command(s)` summary is payload-bearing // (the count) and stays. self.mark_oldest_pending_echo(EchoStatus::Ok); - self.note_system(crate::t!( - "replay.completed", - path = path, - count = count - )); + self.note_system(crate::t!("replay.completed", path = path, count = count)); // ADR-0034: surface `[skip]` warnings for app-lifecycle // commands whose omission can leave the replayed state // incomplete (`import`, nested `replay`). @@ -1084,11 +1077,7 @@ impl App { // it, mirroring how the interactive `running: …` // path renders source-line context above an error. if line_number == 0 { - self.note_error(crate::t!( - "replay.failed_open", - path = path, - error = error - )); + self.note_error(crate::t!("replay.failed_open", path = path, error = error)); } else { self.note_error(crate::t!( "replay.failed_at_line", @@ -1097,10 +1086,7 @@ impl App { error = error )); if !command.is_empty() { - self.note_error(crate::t!( - "replay.command_echo", - command = command - )); + self.note_error(crate::t!("replay.command_echo", command = command)); } } Vec::new() @@ -1237,8 +1223,7 @@ impl App { // stays F1-only. let hint_key = key.code == KeyCode::F(1) || (self.demo_mode - && (key.code, key.modifiers) - == (KeyCode::Char('g'), KeyModifiers::CONTROL)); + && (key.code, key.modifiers) == (KeyCode::Char('g'), KeyModifiers::CONTROL)); if hint_key { if self.input.trim().is_empty() { self.note_hint_for_recent_error(); @@ -1369,8 +1354,8 @@ impl App { /// against crossterm 0.29). Only active in demo mode (the caller /// gates on `self.demo_mode`). fn handle_demo_caption_key(&mut self, key: KeyEvent) -> Option> { - let is_toggle = key.code == KeyCode::Char('5') - && key.modifiers.contains(KeyModifiers::CONTROL); + let is_toggle = + key.code == KeyCode::Char('5') && key.modifiers.contains(KeyModifiers::CONTROL); if self.demo_caption_capturing { if is_toggle { @@ -1379,8 +1364,7 @@ impl App { self.demo_caption_capturing = false; let text = std::mem::take(&mut self.demo_caption_buffer); let trimmed = text.trim(); - self.demo_caption = - (!trimmed.is_empty()).then(|| trimmed.to_string()); + self.demo_caption = (!trimmed.is_empty()).then(|| trimmed.to_string()); } else { match key.code { // Plain characters accumulate invisibly; the prompt @@ -1553,7 +1537,10 @@ impl App { &self.schema_cache, self.effective_mode().as_mode(), )?; - comp.replaced_range = (comp.replaced_range.0 + offset, comp.replaced_range.1 + offset); + comp.replaced_range = ( + comp.replaced_range.0 + offset, + comp.replaced_range.1 + offset, + ); Some(comp) } @@ -1581,8 +1568,7 @@ impl App { idx: usize, ) -> crate::completion::LastCompletion { let inserted = comp.candidates[idx].text.clone(); - let original_text = - self.input[comp.replaced_range.0..comp.replaced_range.1].to_string(); + let original_text = self.input[comp.replaced_range.0..comp.replaced_range.1].to_string(); self.input .replace_range(comp.replaced_range.0..comp.replaced_range.1, &inserted); let new_end = comp.replaced_range.0 + inserted.len(); @@ -1777,7 +1763,10 @@ impl App { // teaching echo (ADR-0038) on an advanced effective mode. let (submission_mode, effective_input) = if self.mode == Mode::Simple && trimmed.starts_with(':') { - (EffectiveMode::AdvancedOneShot, trimmed[1..].trim().to_string()) + ( + EffectiveMode::AdvancedOneShot, + trimmed[1..].trim().to_string(), + ) } else if self.mode == Mode::Advanced { (EffectiveMode::AdvancedPersistent, trimmed.to_string()) } else { @@ -1845,11 +1834,7 @@ impl App { /// simple and advanced modes; the parse-first refactor /// (round-5) routes app commands here before the /// mode-specific DSL/SQL paths. - fn dispatch_app_command( - &mut self, - cmd: crate::dsl::AppCommand, - source: &str, - ) -> Vec { + fn dispatch_app_command(&mut self, cmd: crate::dsl::AppCommand, source: &str) -> Vec { use crate::dsl::{AppCommand, MessagesValue, ModeValue}; debug!(command = ?cmd, "dispatch app command"); match cmd { @@ -2009,11 +1994,8 @@ impl App { // mode so the walker gates SQL-only forms — simple-mode // `select` returns the "this is SQL" hint as a normal // parse error and is rendered through the Err arm below. - match crate::dsl::parser::parse_command_with_schema_in_mode( - input, - &self.schema_cache, - mode, - ) { + match crate::dsl::parser::parse_command_with_schema_in_mode(input, &self.schema_cache, mode) + { Ok(Command::Replay { path }) => { // `replay` is parsed as a DSL command for the // sake of grammar uniformity, but its execution @@ -2127,15 +2109,9 @@ impl App { .get(..*position) .map_or(*position, |s| s.chars().count()); let pad = prefix.chars().count() + chars_before; - self.note_error(crate::t!( - "parse.caret", - padding = " ".repeat(pad) - )); + self.note_error(crate::t!("parse.caret", padding = " ".repeat(pad))); } - self.note_error(crate::t!( - "parse.error", - detail = parse_error_message(&err) - )); + self.note_error(crate::t!("parse.error", detail = parse_error_message(&err))); // ADR-0033 Amendment 3: combine the DSL error with a // pointer to advanced mode when the same line would // run as SQL there. Only in simple mode (a one-shot @@ -2228,7 +2204,11 @@ impl App { | Command::AddRelationship { .. } | Command::DropRelationship { .. } ) { - debug!(verb = command.verb(), width = self.last_output_width, "render: relationship diagrams (ADR-0044)"); + debug!( + verb = command.verb(), + width = self.last_output_width, + "render: relationship diagrams (ADR-0044)" + ); for line in crate::output_render::render_structure_with_diagrams( desc, self.last_output_width, @@ -2252,11 +2232,7 @@ impl App { } } - fn handle_dsl_explain_success( - &mut self, - command: &Command, - plan: &crate::db::QueryPlan, - ) { + fn handle_dsl_explain_success(&mut self, command: &Command, plan: &crate::db::QueryPlan) { self.note_ok_summary(command); // ADR-0028 §3: the display SQL, then the plan tree. // `render_explain_plan` returns ready-built `OutputLine`s @@ -2350,11 +2326,7 @@ impl App { } } - fn handle_dsl_add_column_success( - &mut self, - command: &Command, - result: AddColumnResult, - ) { + fn handle_dsl_add_column_success(&mut self, command: &Command, result: AddColumnResult) { self.note_ok_summary(command); // ADR-0018 §9 / ADR-0038 §6 category 3: emit auto-fill note(s) // before the structure render so the pedagogical "the tool did @@ -2369,19 +2341,12 @@ impl App { self.current_table = Some(result.description); } - fn handle_dsl_drop_column_success( - &mut self, - command: &Command, - result: DropColumnResult, - ) { + fn handle_dsl_drop_column_success(&mut self, command: &Command, result: DropColumnResult) { self.note_ok_summary(command); // ADR-0025: when `--cascade` removed covering indexes, // name each one so the learner sees the side effect. for index in &result.dropped_indexes { - self.note_system(crate::t!( - "ok.index_dropped_with_column", - index = index, - )); + self.note_system(crate::t!("ok.index_dropped_with_column", index = index,)); } for line in crate::output_render::render_structure(&result.description) { self.note_system(line); @@ -2415,10 +2380,7 @@ impl App { lossy = note.lossy ) } else { - crate::t!( - "client_side.transformed", - count = note.transformed - ) + crate::t!("client_side.transformed", count = note.transformed) }; self.push_category_three_prose(line); } @@ -2583,9 +2545,7 @@ impl App { (Operation::RenameTable, Some(table.as_str()), None) } }, - C::SqlCreateTable { name, .. } => { - (Operation::CreateTable, Some(name.as_str()), None) - } + C::SqlCreateTable { name, .. } => (Operation::CreateTable, Some(name.as_str()), None), C::DropTable { name } => (Operation::DropTable, Some(name.as_str()), None), C::SqlDropTable { name, .. } => (Operation::DropTable, Some(name.as_str()), None), C::AddColumn { table, column, .. } => ( @@ -2635,9 +2595,7 @@ impl App { // SQL `CREATE [UNIQUE] INDEX` shares the add-index operation // (it reuses `do_add_index`); route engine/validation errors // through it with the parsed table. - C::SqlCreateIndex { table, .. } => { - (Operation::AddIndex, Some(table.as_str()), None) - } + C::SqlCreateIndex { table, .. } => (Operation::AddIndex, Some(table.as_str()), None), C::AddConstraint { table, column, .. } => ( Operation::AddConstraint, Some(table.as_str()), @@ -2700,19 +2658,13 @@ impl App { // `dispatch_input` routes them through // `dispatch_app_command` before the DSL execution // pipeline that this context builder feeds. - C::App(_) => unreachable!( - "App commands are dispatched before reaching dsl execution" - ), + C::App(_) => unreachable!("App commands are dispatched before reaching dsl execution"), }; TranslateContext { operation: Some(operation), - table: facts - .table - .or_else(|| fallback_table.map(str::to_string)), - column: facts - .column - .or_else(|| fallback_column.map(str::to_string)), + table: facts.table.or_else(|| fallback_table.map(str::to_string)), + column: facts.column.or_else(|| fallback_column.map(str::to_string)), child_table: facts.child_table, parent_table: facts.parent_table, parent_column: facts.parent_column, @@ -2818,11 +2770,7 @@ impl App { } } - fn handle_path_entry_key( - &mut self, - key: KeyEvent, - mut state: PathEntryModal, - ) -> Vec { + fn handle_path_entry_key(&mut self, key: KeyEvent, mut state: PathEntryModal) -> Vec { match key.code { KeyCode::Esc => { self.modal = None; @@ -2904,11 +2852,7 @@ impl App { } } - fn handle_load_picker_key( - &mut self, - key: KeyEvent, - mut state: LoadPickerModal, - ) -> Vec { + fn handle_load_picker_key(&mut self, key: KeyEvent, mut state: LoadPickerModal) -> Vec { match &mut state.sub_mode { LoadPickerSubMode::List => match key.code { KeyCode::Esc => { @@ -3198,7 +3142,10 @@ impl App { .map(|c| c.text.clone()) .collect::>() .join(", "); - self.push_category_three_prose(crate::t!("hint.ambient_expected", expected = names)); + self.push_category_three_prose(crate::t!( + "hint.ambient_expected", + expected = names + )); } None => self.note_getting_started(), } @@ -3413,10 +3360,7 @@ fn render_usage_block(input: &str, mode: Mode) -> String { .into_iter() .map(|w| format!("`{w}`")) .collect(); - crate::t!( - "parse.available_commands", - commands = names.join(", ") - ) + crate::t!("parse.available_commands", commands = names.join(", ")) } fn render_cascade_effect(effect: &CascadeEffect) -> String { @@ -3424,9 +3368,7 @@ fn render_cascade_effect(effect: &CascadeEffect) -> String { let action_key = match effect.action { ReferentialAction::Cascade => "db.cascade.action_deleted", ReferentialAction::SetNull => "db.cascade.action_set_null", - ReferentialAction::Restrict | ReferentialAction::NoAction => { - "db.cascade.action_blocked" - } + ReferentialAction::Restrict | ReferentialAction::NoAction => "db.cascade.action_blocked", }; crate::t!( "db.cascade.summary", @@ -3464,7 +3406,10 @@ mod tests { fn demo_badge_label_maps_the_invisible_keys() { let none = KeyModifiers::NONE; assert_eq!(demo_badge_label(&ke(KeyCode::Tab, none)), Some("[TAB]")); - assert_eq!(demo_badge_label(&ke(KeyCode::BackTab, KeyModifiers::SHIFT)), Some("[SHIFT-TAB]")); + assert_eq!( + demo_badge_label(&ke(KeyCode::BackTab, KeyModifiers::SHIFT)), + Some("[SHIFT-TAB]") + ); assert_eq!(demo_badge_label(&ke(KeyCode::Enter, none)), Some("[ENTER]")); assert_eq!(demo_badge_label(&ke(KeyCode::Esc, none)), Some("[ESC]")); assert_eq!(demo_badge_label(&ke(KeyCode::Up, none)), Some("[UP]")); @@ -3474,8 +3419,14 @@ mod tests { assert_eq!(demo_badge_label(&ke(KeyCode::Home, none)), Some("[HOME]")); assert_eq!(demo_badge_label(&ke(KeyCode::End, none)), Some("[END]")); assert_eq!(demo_badge_label(&ke(KeyCode::PageUp, none)), Some("[PGUP]")); - assert_eq!(demo_badge_label(&ke(KeyCode::PageDown, none)), Some("[PGDN]")); - assert_eq!(demo_badge_label(&ke(KeyCode::Backspace, none)), Some("[BKSP]")); + assert_eq!( + demo_badge_label(&ke(KeyCode::PageDown, none)), + Some("[PGDN]") + ); + assert_eq!( + demo_badge_label(&ke(KeyCode::Backspace, none)), + Some("[BKSP]") + ); assert_eq!(demo_badge_label(&ke(KeyCode::Delete, none)), Some("[DEL]")); assert_eq!( demo_badge_label(&ke(KeyCode::Char('o'), KeyModifiers::CONTROL)), @@ -3493,12 +3444,24 @@ mod tests { #[test] fn demo_badge_label_none_for_glyphs_and_excluded_chords() { // Plain characters render their own glyph — no badge. - assert_eq!(demo_badge_label(&ke(KeyCode::Char('a'), KeyModifiers::NONE)), None); - assert_eq!(demo_badge_label(&ke(KeyCode::Char(' '), KeyModifiers::NONE)), None); + assert_eq!( + demo_badge_label(&ke(KeyCode::Char('a'), KeyModifiers::NONE)), + None + ); + assert_eq!( + demo_badge_label(&ke(KeyCode::Char(' '), KeyModifiers::NONE)), + None + ); // Quit and the (Phase C) caption toggle are deliberately excluded. - assert_eq!(demo_badge_label(&ke(KeyCode::Char('c'), KeyModifiers::CONTROL)), None); + assert_eq!( + demo_badge_label(&ke(KeyCode::Char('c'), KeyModifiers::CONTROL)), + None + ); // Ctrl+] decodes to Char('5')+CONTROL — must not badge. - assert_eq!(demo_badge_label(&ke(KeyCode::Char('5'), KeyModifiers::CONTROL)), None); + assert_eq!( + demo_badge_label(&ke(KeyCode::Char('5'), KeyModifiers::CONTROL)), + None + ); } #[test] @@ -3606,7 +3569,10 @@ mod tests { assert!(app.demo_caption_capturing, "still capturing"); assert_eq!(app.demo_caption_buffer, "note"); assert_eq!(app.input, ""); - assert_eq!(app.demo_badge, None, "inert keys raise no badge while capturing"); + assert_eq!( + app.demo_badge, None, + "inert keys raise no badge while capturing" + ); } #[test] @@ -4211,7 +4177,9 @@ mod tests { type_str(&mut app, "copy sideways"); let actions = submit(&mut app); assert!( - !actions.iter().any(|a| matches!(a, Action::CopyToClipboard(_))), + !actions + .iter() + .any(|a| matches!(a, Action::CopyToClipboard(_))), "an unknown target does not copy", ); let rendered = app @@ -4399,7 +4367,10 @@ mod tests { ); // … names the table's columns so the user can see what's needed … assert!( - out.contains("Name") && out.contains("Age") && out.contains("id") && out.contains("SerNo"), + out.contains("Name") + && out.contains("Age") + && out.contains("id") + && out.contains("SerNo"), "missing the column-name list in: {out}", ); // … and shows the column-list override targeting the non-auto columns. @@ -4423,9 +4394,15 @@ mod tests { let _ = submit(&mut app); let out = error_lines(&app); // The teaching line names the user-supplied columns … - assert!(out.contains("Name") && out.contains("Age"), "missing non-auto column names in: {out}"); + assert!( + out.contains("Name") && out.contains("Age"), + "missing non-auto column names in: {out}" + ); // … the auto-generated columns … - assert!(out.contains("id") && out.contains("SerNo"), "missing auto column names in: {out}"); + assert!( + out.contains("id") && out.contains("SerNo"), + "missing auto column names in: {out}" + ); // … signals the contract … assert!( out.contains("auto-generated"), @@ -4520,10 +4497,7 @@ mod tests { let mut app = App::new(); install_customers_schema_two_serials(&mut app); app.mode = Mode::Advanced; - type_str( - &mut app, - "insert into Customers values (13, 'Oli', 42, 13)", - ); + type_str(&mut app, "insert into Customers values (13, 'Oli', 42, 13)"); let actions = submit(&mut app); assert!( actions @@ -4552,7 +4526,9 @@ mod tests { type_str(&mut app, "insert into Customers values ('Oli', 52, 3)"); let actions = submit(&mut app); assert!( - !actions.iter().any(|a| matches!(a, Action::ExecuteDsl { .. })), + !actions + .iter() + .any(|a| matches!(a, Action::ExecuteDsl { .. })), "simple-mode Form B count mismatch must NOT dispatch; got: {actions:?}", ); } @@ -4565,7 +4541,9 @@ mod tests { type_str(&mut app, "insert into Customers values ('Oli')"); let actions = submit(&mut app); assert!( - !actions.iter().any(|a| matches!(a, Action::ExecuteDsl { .. })), + !actions + .iter() + .any(|a| matches!(a, Action::ExecuteDsl { .. })), "simple-mode Form B under-supply must NOT dispatch; got: {actions:?}", ); } @@ -4580,7 +4558,9 @@ mod tests { type_str(&mut app, "insert into Customers (Name, Age) values ('Oli')"); let actions = submit(&mut app); assert!( - !actions.iter().any(|a| matches!(a, Action::ExecuteDsl { .. })), + !actions + .iter() + .any(|a| matches!(a, Action::ExecuteDsl { .. })), "simple-mode Form A count mismatch must NOT dispatch; got: {actions:?}", ); } @@ -4616,9 +4596,7 @@ mod tests { for c in &tc { app.schema_cache.columns.push(c.name.clone()); } - app.schema_cache - .table_columns - .insert("T".to_string(), tc); + app.schema_cache.table_columns.insert("T".to_string(), tc); type_str(&mut app, "insert into T values (1, 2), (3, 4)"); let actions = submit(&mut app); assert!( @@ -4649,11 +4627,11 @@ mod tests { // advanced-mode hint at all, so we look for any line carrying // the "mode advanced" actionable fragment that the pointer // always emits. - let has_pointer = app - .output - .iter() - .any(|l| l.text.contains("mode advanced")); - assert!(!has_pointer, "unknown command must not point at advanced mode"); + let has_pointer = app.output.iter().any(|l| l.text.contains("mode advanced")); + assert!( + !has_pointer, + "unknown command must not point at advanced mode" + ); } #[test] @@ -4702,7 +4680,11 @@ mod tests { app.mode = mode; type_str(&mut app, input); match submit(&mut app).as_slice() { - [Action::ExecuteDsl { submission_mode, .. }] => *submission_mode, + [ + Action::ExecuteDsl { + submission_mode, .. + }, + ] => *submission_mode, other => panic!("expected one ExecuteDsl; got {other:?}"), } }; @@ -4733,7 +4715,9 @@ mod tests { app.update(AppEvent::DslSucceeded { command: cmd.clone(), description: None, - echo: Some(vec!["CREATE TABLE Other (id serial PRIMARY KEY)".to_string()]), + echo: Some(vec![ + "CREATE TABLE Other (id serial PRIMARY KEY)".to_string(), + ]), }); let texts: Vec<&str> = app.output.iter().map(|l| l.text.as_str()).collect(); // ADR-0040: no `[ok]` summary; with no preceding `running:` echo @@ -4792,7 +4776,10 @@ mod tests { .position(|t| t.contains("Executing SQL:")) .expect("an echo line"); assert_eq!(echo_idx, 0, "teaching echo leads the output: {texts:?}"); - assert!(texts[echo_idx].contains(expected), "echo carries the SQL: {texts:?}"); + assert!( + texts[echo_idx].contains(expected), + "echo carries the SQL: {texts:?}" + ); // ADR-0038 §4 polish: every success arm now wires the echo as // `OutputKind::TeachingEcho` so `ui::render_output_line` fires // the dim-prefix + advanced-lex custom branch. Pinning this @@ -4911,7 +4898,9 @@ mod tests { description: sample_description("T"), client_side: None, }, - echo: Some(vec!["ALTER TABLE T ALTER COLUMN c SET DATA TYPE text".to_string()]), + echo: Some(vec![ + "ALTER TABLE T ALTER COLUMN c SET DATA TYPE text".to_string(), + ]), dont_convert_caveat: false, }); assert_echo_beneath_ok(&app, "ALTER TABLE T ALTER COLUMN c SET DATA TYPE text"); @@ -5126,9 +5115,7 @@ mod tests { dont_convert_caveat: false, }); assert!( - !app.output - .iter() - .any(|l| l.text.contains("--dont-convert")), + !app.output.iter().any(|l| l.text.contains("--dont-convert")), "no caveat in simple mode (no echo to refer to)", ); } @@ -5198,7 +5185,10 @@ mod tests { ); // Pin the `Executing SQL:` prefix repeats once per statement // (the plain-rendering shape until the styled-runs polish lands). - let exec_count = texts.iter().filter(|t| t.contains("Executing SQL:")).count(); + let exec_count = texts + .iter() + .filter(|t| t.contains("Executing SQL:")) + .count(); assert_eq!(exec_count, 3, "one Executing SQL: per statement: {texts:?}"); } @@ -5223,19 +5213,13 @@ mod tests { // (could be the caret line, the parse-error detail // line, or the usage line). Scan for the friendly // "unknown mode" anchor phrase. - let anywhere = app - .output - .iter() - .any(|l| l.text.contains("unknown mode")); + let anywhere = app.output.iter().any(|l| l.text.contains("unknown mode")); assert!( anywhere, "expected 'unknown mode' somewhere in output: {:?}", app.output.iter().map(|l| &l.text).collect::>(), ); - let any_error = app - .output - .iter() - .any(|l| l.kind == OutputKind::Error); + let any_error = app.output.iter().any(|l| l.kind == OutputKind::Error); assert!(any_error, "expected at least one Error line"); } @@ -5325,11 +5309,17 @@ mod tests { app.schema_cache.tables = vec!["Orders".into(), "Customers".into()]; app.schema_cache.table_columns.insert( "Orders".into(), - vec![TableColumn::new("id", Type::Serial), TableColumn::new("customer_id", Type::Int)], + vec![ + TableColumn::new("id", Type::Serial), + TableColumn::new("customer_id", Type::Int), + ], ); app.schema_cache.table_columns.insert( "Customers".into(), - vec![TableColumn::new("id", Type::Serial), TableColumn::new("name", Type::Text)], + vec![ + TableColumn::new("id", Type::Serial), + TableColumn::new("name", Type::Text), + ], ); for t in app.schema_cache.tables.clone() { for c in &app.schema_cache.table_columns[&t] { @@ -5490,10 +5480,7 @@ mod tests { detail: "SCAN Customers".to_string(), }], }; - app.update(AppEvent::DslExplainSucceeded { - command: cmd, - plan, - }); + app.update(AppEvent::DslExplainSucceeded { command: cmd, plan }); // ADR-0040: no `[ok] explain …` header — the (no-echo here) // command's success shows via the marker; the plan output // itself carries the content. @@ -5549,7 +5536,11 @@ mod tests { .iter() .find(|l| l.kind == OutputKind::Echo) .expect("dispatch pushed an echo"); - assert_eq!(echo.status, Some(EchoStatus::Pending), "pending before result"); + assert_eq!( + echo.status, + Some(EchoStatus::Pending), + "pending before result" + ); app.update(AppEvent::DslSucceeded { command: Command::CreateTable { name: "T".to_string(), @@ -5639,8 +5630,14 @@ mod tests { .collect::>() .join("\n"); assert!(text.contains("[ok] replay"), "summary present:\n{text}"); - assert!(text.contains("import a.zip"), "import skip warning rendered:\n{text}"); - assert!(text.contains("nested `replay x`"), "nested-replay skip warning rendered:\n{text}"); + assert!( + text.contains("import a.zip"), + "import skip warning rendered:\n{text}" + ); + assert!( + text.contains("nested `replay x`"), + "nested-replay skip warning rendered:\n{text}" + ); } #[test] @@ -5736,7 +5733,7 @@ mod tests { #[test] fn hint_command_parses_to_app_hint() { - use crate::dsl::{parse_command, AppCommand, Command}; + use crate::dsl::{AppCommand, Command, parse_command}; assert!(matches!( parse_command("hint"), Ok(Command::App(AppCommand::Hint)) @@ -5747,7 +5744,7 @@ mod tests { #[test] fn version_command_parses_to_app_version() { - use crate::dsl::{parse_command, AppCommand, Command}; + use crate::dsl::{AppCommand, Command, parse_command}; assert!(matches!( parse_command("version"), Ok(Command::App(AppCommand::Version)) @@ -5801,7 +5798,10 @@ mod tests { 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"); + 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"); @@ -5827,8 +5827,14 @@ mod tests { let input = app.input.clone(); let before = app.output.len(); app.update(ctrl_g()); - assert_eq!(app.input, input, "Ctrl-G must not change the buffer (no `g` typed)"); - assert!(app.output.len() > before, "Ctrl-G must emit the same hint F1 does"); + assert_eq!( + app.input, input, + "Ctrl-G must not change the buffer (no `g` typed)" + ); + assert!( + app.output.len() > before, + "Ctrl-G must emit the same hint F1 does" + ); } #[test] @@ -5855,7 +5861,11 @@ mod tests { let before = app.output.len(); app.update(ctrl_g()); assert_eq!(app.input, input, "Ctrl-G must not insert a `g`"); - assert_eq!(app.output.len(), before, "Ctrl-G does nothing when demo mode is off"); + assert_eq!( + app.output.len(), + before, + "Ctrl-G does nothing when demo mode is off" + ); } #[test] @@ -5949,7 +5959,10 @@ mod tests { #[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 "); + 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"), @@ -6142,14 +6155,8 @@ mod tests { let mut app = App::new(); let cmd = Command::Update { table: "Customers".to_string(), - assignments: vec![( - "id".to_string(), - crate::dsl::Value::Number("7".to_string()), - )], - filter: crate::dsl::RowFilter::eq( - "name", - crate::dsl::Value::Text("Bob".to_string()), - ), + assignments: vec![("id".to_string(), crate::dsl::Value::Number("7".to_string()))], + filter: crate::dsl::RowFilter::eq("name", crate::dsl::Value::Text("Bob".to_string())), }; let err = crate::db::DbError::Sqlite { message: "UNIQUE constraint failed: Customers.id".to_string(), @@ -6709,7 +6716,10 @@ mod tests { app.update(key(KeyCode::Backspace)); let actions = app.update(key(KeyCode::Enter)); assert_eq!(app.input, "select", "input untouched in navigation mode"); - assert!(actions.is_empty(), "Enter does not submit in navigation mode"); + assert!( + actions.is_empty(), + "Enter does not submit in navigation mode" + ); } #[test] @@ -6720,7 +6730,10 @@ mod tests { app.update(key(KeyCode::Down)); app.update(key(KeyCode::Down)); assert_eq!(app.tables_scroll, 2); - assert_eq!(app.relationships_scroll, 0, "only the focused panel scrolls"); + assert_eq!( + app.relationships_scroll, 0, + "only the focused panel scrolls" + ); app.update(key(KeyCode::Up)); assert_eq!(app.tables_scroll, 1); // Up saturates at the top. @@ -6998,7 +7011,8 @@ mod tests { for round in 0..3 { app.update(key(KeyCode::Up)); assert_eq!( - app.input, "insert into Thing values (1)", + app.input, + "insert into Thing values (1)", "Up #{} should recall the newest entry", round + 1, ); @@ -7220,8 +7234,7 @@ mod tests { has_default: false, }], ); - app.input = - "select * from products where price like 5".to_string(); + app.input = "select * from products where price like 5".to_string(); assert_eq!( app.input_validity_verdict(), Some(crate::dsl::walker::Severity::Warning), @@ -7350,8 +7363,10 @@ mod tests { "directly-deleted count surfaced: {texts:?}", ); assert!( - texts.iter().any(|t| t.contains("2 row(s) deleted in `Orders`") - && t.contains("relationship `places`")), + texts + .iter() + .any(|t| t.contains("2 row(s) deleted in `Orders`") + && t.contains("relationship `places`")), "per-relationship cascade summary surfaced: {texts:?}", ); } @@ -7386,11 +7401,15 @@ mod tests { }); let texts: Vec = app.output.iter().map(|l| l.text.clone()).collect(); assert!( - texts.iter().any(|t| t.contains("20 row(s) seeded into users")), + texts + .iter() + .any(|t| t.contains("20 row(s) seeded into users")), "seeded-row count surfaced: {texts:?}", ); assert!( - texts.iter().any(|t| t.contains("status") && t.contains("generic text")), + texts + .iter() + .any(|t| t.contains("status") && t.contains("generic text")), "the advisory names the enum-ish column: {texts:?}", ); } @@ -7424,8 +7443,9 @@ mod tests { }); let texts: Vec = app.output.iter().map(|l| l.text.clone()).collect(); assert!( - texts.iter().any(|t| t.contains("4 row(s) seeded into J") - && t.contains("of 10 requested")), + texts + .iter() + .any(|t| t.contains("4 row(s) seeded into J") && t.contains("of 10 requested")), "the cap note surfaces requested vs produced: {texts:?}", ); } @@ -7464,7 +7484,9 @@ mod tests { }); let texts: Vec = app.output.iter().map(|l| l.text.clone()).collect(); assert!( - texts.iter().any(|t| t.contains("2 row(s) deleted in `Orders`")), + texts + .iter() + .any(|t| t.contains("2 row(s) deleted in `Orders`")), "cascade summary still surfaces alongside RETURNING: {texts:?}", ); assert!( diff --git a/src/archive.rs b/src/archive.rs index a128773..04cc787 100644 --- a/src/archive.rs +++ b/src/archive.rs @@ -30,9 +30,7 @@ use std::path::{Component, Path, PathBuf}; use tracing::{debug, info}; use zip::{CompressionMethod, ZipArchive, ZipWriter, write::SimpleFileOptions}; -use crate::project::{ - HISTORY_LOG, PLAYGROUND_DB, PROJECT_YAML, naming::today_local, -}; +use crate::project::{HISTORY_LOG, PLAYGROUND_DB, PROJECT_YAML, naming::today_local}; /// File names excluded from `export` zips. These are either /// derived (`playground.db`), per-process (`.lock`), @@ -118,20 +116,14 @@ impl std::fmt::Display for ArchiveError { limit = format_args!("{limit:02}"), )) } - Self::InvalidZip(detail) => f.write_str(&crate::t!( - "archive.invalid_zip", - detail = detail, - )), - Self::NotAProjectArchive => { - f.write_str(&crate::t!("archive.not_a_project_archive")) + Self::InvalidZip(detail) => { + f.write_str(&crate::t!("archive.invalid_zip", detail = detail,)) } - Self::MultipleTopFolders => { - f.write_str(&crate::t!("archive.multiple_top_folders")) + Self::NotAProjectArchive => f.write_str(&crate::t!("archive.not_a_project_archive")), + Self::MultipleTopFolders => f.write_str(&crate::t!("archive.multiple_top_folders")), + Self::UnsafeEntry(entry) => { + f.write_str(&crate::t!("archive.unsafe_entry", entry = entry,)) } - Self::UnsafeEntry(entry) => f.write_str(&crate::t!( - "archive.unsafe_entry", - entry = entry, - )), } } } @@ -216,13 +208,7 @@ pub fn export_project( .unix_permissions(0o644); add_directory_entry(&mut writer, project_name, dst_zip)?; - add_directory_recursive( - &mut writer, - project_path, - project_name, - &options, - dst_zip, - )?; + add_directory_recursive(&mut writer, project_path, project_name, &options, dst_zip)?; writer.finish().map_err(|e| ArchiveError::Zip { path: dst_zip.to_path_buf(), @@ -392,10 +378,7 @@ pub struct ZipInspection { /// /// Returns the resolved target path and the suffix that was /// applied (0 if the original name was free, 2..=99 otherwise). -pub fn resolve_import_target( - parent: &Path, - name: &str, -) -> Result<(PathBuf, u32), ArchiveError> { +pub fn resolve_import_target(parent: &Path, name: &str) -> Result<(PathBuf, u32), ArchiveError> { let direct = parent.join(name); if !direct.exists() { return Ok((direct, 0)); @@ -495,10 +478,12 @@ pub fn extract_into( source, })?; let mut buf = Vec::with_capacity(entry.size() as usize); - entry.read_to_end(&mut buf).map_err(|source| ArchiveError::Io { - path: dst_path.clone(), - source, - })?; + entry + .read_to_end(&mut buf) + .map_err(|source| ArchiveError::Io { + path: dst_path.clone(), + source, + })?; out.write_all(&buf).map_err(|source| ArchiveError::Io { path: dst_path.clone(), source, @@ -523,7 +508,11 @@ mod tests { fs::write(p.join(PROJECT_YAML), "version: 1\nproject:\n created_at: 2026-01-01T00:00:00Z\ntables: []\nrelationships: []\n").unwrap(); fs::create_dir_all(p.join("data")).unwrap(); fs::write(p.join("data/Customers.csv"), "Name\nAlice\nBob\n").unwrap(); - fs::write(p.join(HISTORY_LOG), "T|ok|create table Customers with pk id(serial)\n").unwrap(); + fs::write( + p.join(HISTORY_LOG), + "T|ok|create table Customers with pk id(serial)\n", + ) + .unwrap(); fs::write(p.join(PLAYGROUND_DB), [0u8; 32]).unwrap(); fs::write(p.join(GITIGNORE), "/playground.db\n").unwrap(); // Stray atomic-write staging file — must be excluded. @@ -536,7 +525,9 @@ mod tests { ) .unwrap(); fs::write( - p.join(crate::undo::SNAPSHOTS_DIR).join("0").join(PLAYGROUND_DB), + p.join(crate::undo::SNAPSHOTS_DIR) + .join("0") + .join(PLAYGROUND_DB), [0u8; 16], ) .unwrap(); @@ -618,11 +609,15 @@ mod tests { let zip_path = tmp.path().join("notaproject.zip"); let f = fs::File::create(&zip_path).unwrap(); let mut w = ZipWriter::new(f); - w.start_file("foo/bar.txt", SimpleFileOptions::default()).unwrap(); + w.start_file("foo/bar.txt", SimpleFileOptions::default()) + .unwrap(); w.write_all(b"hi").unwrap(); w.finish().unwrap(); let err = inspect_zip(&zip_path).expect_err("must refuse"); - assert!(matches!(err, ArchiveError::NotAProjectArchive), "got: {err:?}"); + assert!( + matches!(err, ArchiveError::NotAProjectArchive), + "got: {err:?}" + ); } #[test] @@ -631,13 +626,18 @@ mod tests { let zip_path = tmp.path().join("multi.zip"); let f = fs::File::create(&zip_path).unwrap(); let mut w = ZipWriter::new(f); - w.start_file("a/project.yaml", SimpleFileOptions::default()).unwrap(); + w.start_file("a/project.yaml", SimpleFileOptions::default()) + .unwrap(); w.write_all(b"x").unwrap(); - w.start_file("b/project.yaml", SimpleFileOptions::default()).unwrap(); + w.start_file("b/project.yaml", SimpleFileOptions::default()) + .unwrap(); w.write_all(b"x").unwrap(); w.finish().unwrap(); let err = inspect_zip(&zip_path).expect_err("must refuse"); - assert!(matches!(err, ArchiveError::MultipleTopFolders), "got: {err:?}"); + assert!( + matches!(err, ArchiveError::MultipleTopFolders), + "got: {err:?}" + ); } #[test] diff --git a/src/cli.rs b/src/cli.rs index 9796431..b3155a4 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -96,10 +96,7 @@ pub enum ArgsError { impl std::fmt::Display for ArgsError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - Self::MissingValue(flag) => f.write_str(&crate::t!( - "cli.missing_value", - flag = flag, - )), + Self::MissingValue(flag) => f.write_str(&crate::t!("cli.missing_value", flag = flag,)), Self::InvalidValue { flag, value, @@ -110,10 +107,7 @@ impl std::fmt::Display for ArgsError { value = value, expected = expected, )), - Self::Unknown(arg) => f.write_str(&crate::t!( - "cli.unknown_argument", - arg = arg, - )), + Self::Unknown(arg) => f.write_str(&crate::t!("cli.unknown_argument", arg = arg,)), Self::MultiplePaths { first, second } => f.write_str(&crate::t!( "cli.multiple_paths", first = first, @@ -261,7 +255,11 @@ fn default_theme() -> Theme { // Standard convention: 0..=6 and 8 are dark backgrounds, // 7 and 9..=15 are light. ITerm emits 15 for white-ish. let is_dark = matches!(code, 0..=6 | 8); - return if is_dark { Theme::dark() } else { Theme::light() }; + return if is_dark { + Theme::dark() + } else { + Theme::light() + }; } Theme::default() } @@ -314,10 +312,19 @@ mod tests { #[test] fn mode_flag_simple_and_advanced() { - assert_eq!(Args::parse(["--mode", "simple"]).unwrap().mode, Some(Mode::Simple)); - assert_eq!(Args::parse(["--mode", "advanced"]).unwrap().mode, Some(Mode::Advanced)); + assert_eq!( + Args::parse(["--mode", "simple"]).unwrap().mode, + Some(Mode::Simple) + ); + assert_eq!( + Args::parse(["--mode", "advanced"]).unwrap().mode, + Some(Mode::Advanced) + ); // Case-insensitive, like the `mode` command. - assert_eq!(Args::parse(["--mode", "ADVANCED"]).unwrap().mode, Some(Mode::Advanced)); + assert_eq!( + Args::parse(["--mode", "ADVANCED"]).unwrap().mode, + Some(Mode::Advanced) + ); } #[test] @@ -350,7 +357,10 @@ mod tests { #[test] fn data_dir_flag_parses() { let args = Args::parse(["--data-dir", "/tmp/playground-data"]).unwrap(); - assert_eq!(args.data_dir.as_deref(), Some(std::path::Path::new("/tmp/playground-data"))); + assert_eq!( + args.data_dir.as_deref(), + Some(std::path::Path::new("/tmp/playground-data")) + ); } #[test] @@ -370,13 +380,11 @@ mod tests { #[test] fn data_dir_and_positional_can_coexist() { - let args = Args::parse([ - "--data-dir", - "/tmp/data", - "/home/me/MyProject", - ]) - .unwrap(); - assert_eq!(args.data_dir.as_deref(), Some(std::path::Path::new("/tmp/data"))); + let args = Args::parse(["--data-dir", "/tmp/data", "/home/me/MyProject"]).unwrap(); + assert_eq!( + args.data_dir.as_deref(), + Some(std::path::Path::new("/tmp/data")) + ); assert_eq!( args.project_path.as_deref(), Some(std::path::Path::new("/home/me/MyProject")) @@ -386,7 +394,10 @@ mod tests { #[test] fn two_positional_paths_error() { let err = Args::parse(["/a", "/b"]).unwrap_err(); - assert!(matches!(err, ArgsError::MultiplePaths { .. }), "got: {err:?}"); + assert!( + matches!(err, ArgsError::MultiplePaths { .. }), + "got: {err:?}" + ); } #[test] @@ -455,7 +466,10 @@ mod tests { // Absent `--demo` (and absent env var in the test runner), // demo mode is off — zero footprint for real users. let args = Args::parse(std::iter::empty::<&str>()).unwrap(); - assert!(!args.demo, "demo is off unless --demo or the env var is given"); + assert!( + !args.demo, + "demo is off unless --demo or the env var is given" + ); } #[test] @@ -484,7 +498,10 @@ mod tests { } // Disabling values. for v in ["", " ", "0", "false", "False", "no", "off", "OFF"] { - assert!(!demo_value_is_truthy(v), "{v:?} should not enable demo mode"); + assert!( + !demo_value_is_truthy(v), + "{v:?} should not enable demo mode" + ); } } @@ -493,7 +510,10 @@ mod tests { // Make sure the path-vs-flag distinction is robust: // unknown flags don't get silently swallowed as paths. let err = Args::parse(["--bogus", "/some/path"]).unwrap_err(); - assert!(matches!(&err, ArgsError::Unknown(s) if s == "--bogus"), "got: {err:?}"); + assert!( + matches!(&err, ArgsError::Unknown(s) if s == "--bogus"), + "got: {err:?}" + ); } // ---- ADR-0054: --version / -V ---- diff --git a/src/completion.rs b/src/completion.rs index 10b319a..ad8eb42 100644 --- a/src/completion.rs +++ b/src/completion.rs @@ -15,10 +15,10 @@ //! `app.rs`; this module owns the candidate computation. use crate::dsl::grammar::IdentSource; +use crate::dsl::parser::parse_command_with_schema_in_mode; use crate::dsl::types::Type; use crate::dsl::walker::outcome::Expectation; use crate::dsl::{ParseError, parse_command}; -use crate::dsl::parser::parse_command_with_schema_in_mode; use crate::mode::Mode; /// Composite literal candidates whose lexed shape is more than @@ -275,11 +275,7 @@ pub struct Completion { /// (case-insensitive starts-with), combined, sorted, and /// deduplicated. #[must_use] -pub fn candidates_at_cursor( - input: &str, - cursor: usize, - cache: &SchemaCache, -) -> Option { +pub fn candidates_at_cursor(input: &str, cursor: usize, cache: &SchemaCache) -> Option { candidates_at_cursor_in_mode(input, cursor, cache, Mode::Advanced) } @@ -358,7 +354,11 @@ pub fn candidates_at_cursor_with_in_mode( let word_boundary = run == 0 || bytes[run - 1].is_ascii_whitespace(); if run < cursor && bytes[run] == b'-' && word_boundary && run < start { let pre = crate::dsl::walker::completion_probe_in_mode(&input[..run], cache, mode); - if pre.expected.iter().any(|e| matches!(e, Expectation::Flag(_))) { + if pre + .expected + .iter() + .any(|e| matches!(e, Expectation::Flag(_))) + { start = run; } } @@ -473,22 +473,19 @@ pub fn candidates_at_cursor_with_in_mode( // walk's `current_table_columns`; fall back to "the union of // the look-ahead from_scope's bindings' columns" when leading // produced no in-scope columns. Phase-1 DSL paths unaffected. - let lookahead_union_columns: Vec = - if probe.current_table_columns.is_none() { - let mut out: Vec = Vec::new(); - for binding in resolution_from_scope { - for col in &binding.columns { - if !out.iter().any(|c| { - c.name.eq_ignore_ascii_case(&col.name) - }) { - out.push(col.clone()); - } + let lookahead_union_columns: Vec = if probe.current_table_columns.is_none() { + let mut out: Vec = Vec::new(); + for binding in resolution_from_scope { + for col in &binding.columns { + if !out.iter().any(|c| c.name.eq_ignore_ascii_case(&col.name)) { + out.push(col.clone()); } } - out - } else { - Vec::new() - }; + } + out + } else { + Vec::new() + }; let lookahead_slice: Option<&[TableColumn]> = if lookahead_union_columns.is_empty() { None } else { @@ -507,30 +504,23 @@ pub fn candidates_at_cursor_with_in_mode( // column list (the structural error path surfaces the // unresolved-prefix message). let prefix_qualifier = peek_back_qualifier(input, start); - let qualified_columns: Option> = prefix_qualifier - .as_ref() - .map(|q| { - // ADR-0033 §9: `excluded.|` inside an `INSERT … ON - // CONFLICT … DO UPDATE` completes to the target table's - // columns — `excluded` mirrors the would-be-inserted row. - // The target's columns are the INSERT's - // `current_table_columns` (set by the target-table slot). - // The diagnostic pass enforces the strict DO-UPDATE - // byte-range; completion is the softer surface and offers - // the columns whenever the INSERT target is in hand. - if q.eq_ignore_ascii_case("excluded") - && let Some(cols) = current_table_columns - { - cols.iter().map(|c| c.name.clone()).collect() - } else { - resolve_qualifier_columns_in( - q, - resolution_from_scope, - resolution_cte_bindings, - cache, - ) - } - }); + let qualified_columns: Option> = prefix_qualifier.as_ref().map(|q| { + // ADR-0033 §9: `excluded.|` inside an `INSERT … ON + // CONFLICT … DO UPDATE` completes to the target table's + // columns — `excluded` mirrors the would-be-inserted row. + // The target's columns are the INSERT's + // `current_table_columns` (set by the target-table slot). + // The diagnostic pass enforces the strict DO-UPDATE + // byte-range; completion is the softer surface and offers + // the columns whenever the INSERT target is in hand. + if q.eq_ignore_ascii_case("excluded") + && let Some(cols) = current_table_columns + { + cols.iter().map(|c| c.name.clone()).collect() + } else { + resolve_qualifier_columns_in(q, resolution_from_scope, resolution_cte_bindings, cache) + } + }); let expected = if probe.expected.is_empty() { expected_at(leading, mode) @@ -574,8 +564,7 @@ pub fn candidates_at_cursor_with_in_mode( Some(crate::dsl::grammar::HintMode::ProseOnly(_)) ); if partial_prefix.is_empty() - && (prose_only_slot - || (is_value_literal_signature(&expected) && !has_schema_ident)) + && (prose_only_slot || (is_value_literal_signature(&expected) && !has_schema_ident)) { return None; } @@ -646,7 +635,13 @@ pub fn candidates_at_cursor_with_in_mode( // shortid). The walker surfaces this as // `Expectation::Ident { source: Types }`. let type_names: Vec = if expected.iter().any(|e| { - matches!(e, Expectation::Ident { source: IdentSource::Types, .. }) + matches!( + e, + Expectation::Ident { + source: IdentSource::Types, + .. + } + ) }) { Type::all() .iter() @@ -725,7 +720,13 @@ pub fn candidates_at_cursor_with_in_mode( // filtered like every other source; empty prefix offers the whole // set. Tagged `CandidateKind::Function` for its own colour. let has_sql_expr_slot = expected.iter().any(|e| { - matches!(e, Expectation::Ident { role: "sql_expr_ident", .. }) + matches!( + e, + Expectation::Ident { + role: "sql_expr_ident", + .. + } + ) }); let mut functions: Vec = if has_sql_expr_slot { crate::dsl::sql_functions::KNOWN_SQL_FUNCTIONS @@ -741,9 +742,15 @@ pub fn candidates_at_cursor_with_in_mode( // curated vocabulary is offered so a learner can discover `email` / // `product` / … by Tab. Same `Function` kind / `tok_function` colour // as SQL functions (no new theme colour — ADR-0048 §Grammar). - let has_generator_slot = expected - .iter() - .any(|e| matches!(e, Expectation::Ident { source: IdentSource::Generators, .. })); + let has_generator_slot = expected.iter().any(|e| { + matches!( + e, + Expectation::Ident { + source: IdentSource::Generators, + .. + } + ) + }); if has_generator_slot { functions.extend( crate::seed::KNOWN_GENERATORS @@ -765,38 +772,36 @@ pub fn candidates_at_cursor_with_in_mode( // (the `typing_over_diag` path) — keeps the alias from flashing as // a bogus "unknown column" while typing. Mixed into `identifiers` // so it sorts/dedups/colours uniformly with column candidates. - let alias_candidates: Vec = - if has_sql_expr_slot && prefix_qualifier.is_none() { - // Once the partial *exactly* matches an in-scope qualifier, - // discoverability is served — the learner has a whole alias - // in hand and now needs the "add `.column`" hint - // (`diagnostic.alias_used_as_column`), not sibling aliases - // that merely share the prefix. Offering them would also let - // the `typing_over_diag` path suppress that very hint. So in - // the exact-match case we emit no alias candidates and let - // the targeted diagnostic surface. - let partial_is_exact_alias = resolution_from_scope.iter().any(|b| { - let q = b.alias.as_deref().unwrap_or(b.table.as_str()); - q.eq_ignore_ascii_case(&partial_prefix) - }); - if partial_is_exact_alias { - Vec::new() - } else { - let mut out: Vec = Vec::new(); - for binding in resolution_from_scope { - let qualifier = - binding.alias.as_deref().unwrap_or(binding.table.as_str()); - if matches_prefix(qualifier) - && !out.iter().any(|q| q.eq_ignore_ascii_case(qualifier)) - { - out.push(qualifier.to_string()); - } - } - out - } - } else { + let alias_candidates: Vec = if has_sql_expr_slot && prefix_qualifier.is_none() { + // Once the partial *exactly* matches an in-scope qualifier, + // discoverability is served — the learner has a whole alias + // in hand and now needs the "add `.column`" hint + // (`diagnostic.alias_used_as_column`), not sibling aliases + // that merely share the prefix. Offering them would also let + // the `typing_over_diag` path suppress that very hint. So in + // the exact-match case we emit no alias candidates and let + // the targeted diagnostic surface. + let partial_is_exact_alias = resolution_from_scope.iter().any(|b| { + let q = b.alias.as_deref().unwrap_or(b.table.as_str()); + q.eq_ignore_ascii_case(&partial_prefix) + }); + if partial_is_exact_alias { Vec::new() - }; + } else { + let mut out: Vec = Vec::new(); + for binding in resolution_from_scope { + let qualifier = binding.alias.as_deref().unwrap_or(binding.table.as_str()); + if matches_prefix(qualifier) + && !out.iter().any(|q| q.eq_ignore_ascii_case(qualifier)) + { + out.push(qualifier.to_string()); + } + } + out + } + } else { + Vec::new() + }; // Source 2: schema identifiers — accumulated across every // matching schema-listable `Ident { source }` expectation. @@ -811,9 +816,7 @@ pub fn candidates_at_cursor_with_in_mode( let mut identifiers: Vec = expected .iter() .filter_map(|e| match e { - Expectation::Ident { source, .. } if source.completes_from_schema() => { - Some(*source) - } + Expectation::Ident { source, .. } if source.completes_from_schema() => Some(*source), _ => None, }) .flat_map(|source| { @@ -1007,11 +1010,7 @@ fn resolve_qualifier_columns_in( .iter() .find(|c| c.name.eq_ignore_ascii_case(&binding.table)) { - return cte - .columns - .iter() - .filter_map(|c| c.name.clone()) - .collect(); + return cte.columns.iter().filter_map(|c| c.name.clone()).collect(); } } // Second: table-name match in the active from_scope. @@ -1026,11 +1025,7 @@ fn resolve_qualifier_columns_in( .iter() .find(|c| c.name.eq_ignore_ascii_case(&binding.table)) { - return cte - .columns - .iter() - .filter_map(|c| c.name.clone()) - .collect(); + return cte.columns.iter().filter_map(|c| c.name.clone()).collect(); } } // Third: direct cte_bindings match (cte_alias.|). @@ -1038,11 +1033,7 @@ fn resolve_qualifier_columns_in( .iter() .find(|c| c.name.eq_ignore_ascii_case(qualifier)) { - return cte - .columns - .iter() - .filter_map(|c| c.name.clone()) - .collect(); + return cte.columns.iter().filter_map(|c| c.name.clone()).collect(); } // Fourth: a bare table name from the schema cache — DSL // paths reach this for `from .` shapes where @@ -1287,7 +1278,13 @@ pub fn invalid_ident_at_cursor_in_mode( // column. So `select Agx` warns at typing time again, while // `select sum` does not. let has_sql_expr_slot = expected.iter().any(|e| { - matches!(e, Expectation::Ident { role: "sql_expr_ident", .. }) + matches!( + e, + Expectation::Ident { + role: "sql_expr_ident", + .. + } + ) }); if has_sql_expr_slot && crate::dsl::sql_functions::is_known_function_prefix(partial) { return None; @@ -1318,9 +1315,15 @@ pub fn invalid_ident_at_cursor_in_mode( // schema-column check below would never see it. A partial that // prefix-matches a known generator is an in-progress name; anything // else is an unknown generator → flag it `[ERR]` while typing. - let has_generator_slot = expected - .iter() - .any(|e| matches!(e, Expectation::Ident { source: IdentSource::Generators, .. })); + let has_generator_slot = expected.iter().any(|e| { + matches!( + e, + Expectation::Ident { + source: IdentSource::Generators, + .. + } + ) + }); if has_generator_slot { if crate::seed::is_known_generator_prefix(partial) { return None; @@ -1335,9 +1338,7 @@ pub fn invalid_ident_at_cursor_in_mode( let sources: Vec = expected .iter() .filter_map(|e| match e { - Expectation::Ident { source, .. } if source.completes_from_schema() => { - Some(*source) - } + Expectation::Ident { source, .. } if source.completes_from_schema() => Some(*source), _ => None, }) .collect(); @@ -1412,13 +1413,15 @@ mod tests { use pretty_assertions::assert_eq; fn cands(input: &str, cursor: usize) -> Vec { - candidates_at_cursor(input, cursor, &SchemaCache::default()) - .map_or_else(Vec::new, |c| c.candidates.into_iter().map(|c| c.text).collect()) + candidates_at_cursor(input, cursor, &SchemaCache::default()).map_or_else(Vec::new, |c| { + c.candidates.into_iter().map(|c| c.text).collect() + }) } fn cands_with(input: &str, cursor: usize, cache: &SchemaCache) -> Vec { - candidates_at_cursor(input, cursor, cache) - .map_or_else(Vec::new, |c| c.candidates.into_iter().map(|c| c.text).collect()) + candidates_at_cursor(input, cursor, cache).map_or_else(Vec::new, |c| { + c.candidates.into_iter().map(|c| c.text).collect() + }) } /// Simple-mode completion candidates — the DSL surface @@ -1429,7 +1432,9 @@ mod tests { /// Advanced mode surfaces the SQL grammar's completions instead. fn cands_simple(input: &str, cursor: usize) -> Vec { candidates_at_cursor_in_mode(input, cursor, &SchemaCache::default(), Mode::Simple) - .map_or_else(Vec::new, |c| c.candidates.into_iter().map(|c| c.text).collect()) + .map_or_else(Vec::new, |c| { + c.candidates.into_iter().map(|c| c.text).collect() + }) } fn cand_kinds_with( @@ -1438,10 +1443,7 @@ mod tests { cache: &SchemaCache, ) -> Vec<(String, CandidateKind)> { candidates_at_cursor(input, cursor, cache).map_or_else(Vec::new, |c| { - c.candidates - .into_iter() - .map(|c| (c.text, c.kind)) - .collect() + c.candidates.into_iter().map(|c| (c.text, c.kind)).collect() }) } @@ -1503,12 +1505,21 @@ mod tests { // Simple-only (column, relationship, constraint). let cs = cands("drop ", 5); for kw in ["table", "index", "column", "relationship", "constraint"] { - assert!(cs.contains(&kw.to_string()), "`drop ` should offer `{kw}`; got {cs:?}"); + assert!( + cs.contains(&kw.to_string()), + "`drop ` should offer `{kw}`; got {cs:?}" + ); } // Both-mode continuations block before the simple-only ones. let pos = |k: &str| cs.iter().position(|c| c == k).unwrap(); - assert!(pos("table") < pos("column"), "Both block precedes Simple block: {cs:?}"); - assert!(pos("index") < pos("relationship"), "Both block precedes Simple block: {cs:?}"); + assert!( + pos("table") < pos("column"), + "Both block precedes Simple block: {cs:?}" + ); + assert!( + pos("index") < pos("relationship"), + "Both block precedes Simple block: {cs:?}" + ); } #[test] @@ -1631,8 +1642,14 @@ mod tests { let c = candidates_at_cursor(input, input.len(), &SchemaCache::default()) .expect("a `-` at a flag position offers candidates"); let texts: Vec<&str> = c.candidates.iter().map(|x| x.text.as_str()).collect(); - assert!(texts.contains(&"--create-fk"), "should offer --create-fk: {texts:?}"); - assert!(!texts.contains(&"on"), "must NOT offer `on` after a dash: {texts:?}"); + assert!( + texts.contains(&"--create-fk"), + "should offer --create-fk: {texts:?}" + ); + assert!( + !texts.contains(&"on"), + "must NOT offer `on` after a dash: {texts:?}" + ); assert_eq!( c.replaced_range, (input.len() - 1, input.len()), @@ -1643,13 +1660,9 @@ mod tests { #[test] fn double_dash_replaces_both_dashes_on_accept() { let input = "delete from T --"; - let c = candidates_at_cursor_in_mode( - input, - input.len(), - &SchemaCache::default(), - Mode::Simple, - ) - .expect("`--` offers the flag"); + let c = + candidates_at_cursor_in_mode(input, input.len(), &SchemaCache::default(), Mode::Simple) + .expect("`--` offers the flag"); assert!(c.candidates.iter().any(|x| x.text == "--all-rows")); assert_eq!( c.replaced_range, @@ -1668,9 +1681,7 @@ mod tests { s.tables.push("T".into()); s.columns.push("x".into()); let input = "show data T where x = -5"; - if let Some(c) = - candidates_at_cursor_in_mode(input, input.len(), &s, Mode::Simple) - { + if let Some(c) = candidates_at_cursor_in_mode(input, input.len(), &s, Mode::Simple) { assert!( !c.candidates.iter().any(|x| x.text.starts_with("--")), "no flags at a value position: {:?}", @@ -1715,8 +1726,8 @@ mod tests { // App-lifecycle commands now appear alongside DSL // commands in the entry-keyword set. for expected in &[ - "quit", "help", "rebuild", "save", "new", "load", "export", - "import", "mode", "messages", "undo", "redo", "copy", + "quit", "help", "rebuild", "save", "new", "load", "export", "import", "mode", + "messages", "undo", "redo", "copy", ] { assert!( cs.contains(&expected.to_string()), @@ -1943,7 +1954,10 @@ mod tests { // opening a sub-shape) becomes a Tab candidate. let input = "add column to table T"; let cs = cands(input, input.len()); - assert!(cs.is_empty(), "trailing-content punct should not surface: {cs:?}"); + assert!( + cs.is_empty(), + "trailing-content punct should not surface: {cs:?}" + ); } #[test] @@ -1957,10 +1971,7 @@ mod tests { assert!(cs.contains(&"(".to_string()), "got {cs:?}"); } - fn schema_with_table( - table: &str, - columns: &[(&str, crate::dsl::types::Type)], - ) -> SchemaCache { + fn schema_with_table(table: &str, columns: &[(&str, crate::dsl::types::Type)]) -> SchemaCache { let mut cache = SchemaCache::default(); cache.tables.push(table.to_string()); let cols: Vec = columns @@ -2002,8 +2013,14 @@ mod tests { let cache = two_table_alias_cache(); let input = "select a.id from a o join b z on o.id = z.id group by "; let cs = cands_with(input, input.len(), &cache); - assert!(cs.contains(&"o".to_string()), "alias `o` must be offered; got {cs:?}"); - assert!(cs.contains(&"z".to_string()), "alias `z` must be offered; got {cs:?}"); + assert!( + cs.contains(&"o".to_string()), + "alias `o` must be offered; got {cs:?}" + ); + assert!( + cs.contains(&"z".to_string()), + "alias `z` must be offered; got {cs:?}" + ); } #[test] @@ -2015,8 +2032,14 @@ mod tests { let cache = two_table_alias_cache(); let input = "select a.id from a aa join b ab on aa.id = ab.id group by a"; let cs = cands_with(input, input.len(), &cache); - assert!(cs.contains(&"aa".to_string()), "alias `aa` must be offered; got {cs:?}"); - assert!(cs.contains(&"ab".to_string()), "alias `ab` must be offered; got {cs:?}"); + assert!( + cs.contains(&"aa".to_string()), + "alias `aa` must be offered; got {cs:?}" + ); + assert!( + cs.contains(&"ab".to_string()), + "alias `ab` must be offered; got {cs:?}" + ); // Exact-alias partial: the alias source steps aside. let exact = "select aa.id from a aa join b ab on aa.id = ab.id group by aa"; @@ -2046,19 +2069,20 @@ mod tests { // SchemaCache.columns has columns from many tables, but // at `update Customers set ` only Customers' columns // should appear. - let mut cache = schema_with_table( - "Customers", - &[("id", Type::Int), ("Email", Type::Text)], - ); + let mut cache = schema_with_table("Customers", &[("id", Type::Int), ("Email", Type::Text)]); // Pretend the global flat list has columns from a second // table that aren't in Customers. cache.columns.push("OrderTotal".to_string()); cache.columns.push("Stock".to_string()); - cache - .table_columns - .insert("Orders".to_string(), vec![ - TableColumn { name: "OrderTotal".to_string(), user_type: Type::Real, not_null: false, has_default: false }, - ]); + cache.table_columns.insert( + "Orders".to_string(), + vec![TableColumn { + name: "OrderTotal".to_string(), + user_type: Type::Real, + not_null: false, + has_default: false, + }], + ); cache.tables.push("Orders".to_string()); let cs = cands_with("update Customers set ", 21, &cache); // Customers's columns should appear: @@ -2079,10 +2103,7 @@ mod tests { // *before* ORDER BY (the FROM's JOIN options, WHERE / // GROUP BY / HAVING, set-ops). Those used to shove the // columns off-screen. - let cache = schema_with_table( - "Things", - &[("Name", Type::Text), ("Qty", Type::Int)], - ); + let cache = schema_with_table("Things", &[("Name", Type::Text), ("Qty", Type::Int)]); let input = "select Name from Things order by "; let cs = cands_with(input, input.len(), &cache); // The columns the user wants are offered: @@ -2090,8 +2111,19 @@ mod tests { assert!(cs.contains(&"Qty".to_string()), "got {cs:?}"); // Preceding-clause keywords must not leak in: for kw in [ - "where", "group", "having", "join", "union", "intersect", - "except", "left", "right", "full", "cross", "inner", "as", + "where", + "group", + "having", + "join", + "union", + "intersect", + "except", + "left", + "right", + "full", + "cross", + "inner", + "as", ] { assert!( !cs.contains(&kw.to_string()), @@ -2108,10 +2140,7 @@ mod tests { // sort item the direction keywords surface as // continuations (previously discarded at the Repeated // boundary, so completion offered neither). - let cache = schema_with_table( - "Things", - &[("Name", Type::Text), ("Qty", Type::Int)], - ); + let cache = schema_with_table("Things", &[("Name", Type::Text), ("Qty", Type::Int)]); let input = "select Name from Things order by Name "; let cs = cands_with(input, input.len(), &cache); assert!(cs.contains(&"asc".to_string()), "got {cs:?}"); @@ -2123,10 +2152,7 @@ mod tests { use crate::dsl::types::Type; // walk_repeated trailing-optional fix: after a complete // projection item the `as` alias keyword surfaces. - let cache = schema_with_table( - "Things", - &[("Name", Type::Text), ("Qty", Type::Int)], - ); + let cache = schema_with_table("Things", &[("Name", Type::Text), ("Qty", Type::Int)]); let input = "select Name "; let cs = cands_with(input, input.len(), &cache); assert!(cs.contains(&"as".to_string()), "got {cs:?}"); @@ -2153,16 +2179,13 @@ mod tests { // ADR-0022 Amendment 2: at an expression position offering // both column names and keywords, every column precedes // every keyword so the names stay visible by default. - let cache = schema_with_table( - "Things", - &[("Name", Type::Text), ("Qty", Type::Int)], - ); + let cache = schema_with_table("Things", &[("Name", Type::Text), ("Qty", Type::Int)]); let input = "select * from Things where "; let cs = cands_with(input, input.len(), &cache); let pos = |needle: &str| { - cs.iter().position(|c| c == needle).unwrap_or_else(|| { - panic!("{needle:?} not in candidates: {cs:?}") - }) + cs.iter() + .position(|c| c == needle) + .unwrap_or_else(|| panic!("{needle:?} not in candidates: {cs:?}")) }; // Both columns come before any expression-start keyword. let last_ident = pos("Name").max(pos("Qty")); @@ -2176,13 +2199,9 @@ mod tests { #[test] fn update_where_offers_only_current_table_columns() { use crate::dsl::types::Type; - let mut cache = schema_with_table( - "Customers", - &[("id", Type::Int), ("Email", Type::Text)], - ); + let mut cache = schema_with_table("Customers", &[("id", Type::Int), ("Email", Type::Text)]); cache.columns.push("OrderTotal".to_string()); - let cs = - cands_with("update Customers set Email='x' where ", 37, &cache); + let cs = cands_with("update Customers set Email='x' where ", 37, &cache); assert!(cs.contains(&"id".to_string()), "got {cs:?}"); assert!(cs.contains(&"Email".to_string()), "got {cs:?}"); assert!(!cs.contains(&"OrderTotal".to_string()), "got {cs:?}"); @@ -2208,7 +2227,11 @@ mod tests { use crate::dsl::types::Type; let cache = schema_with_table( "Customers", - &[("id", Type::Int), ("Email", Type::Text), ("Name", Type::Text)], + &[ + ("id", Type::Int), + ("Email", Type::Text), + ("Name", Type::Text), + ], ); let cs = cands_with("insert into Customers (", 23, &cache); // The user is at Form A's column-list position. All @@ -2222,10 +2245,7 @@ mod tests { #[test] fn insert_into_open_paren_does_not_offer_unrelated_columns() { use crate::dsl::types::Type; - let mut cache = schema_with_table( - "Customers", - &[("id", Type::Int), ("Email", Type::Text)], - ); + let mut cache = schema_with_table("Customers", &[("id", Type::Int), ("Email", Type::Text)]); cache.columns.push("OrderTotal".to_string()); let cs = cands_with("insert into Customers (", 23, &cache); assert!(!cs.contains(&"OrderTotal".to_string()), "got {cs:?}"); @@ -2239,13 +2259,9 @@ mod tests { // table's columns. `OrderTotal` belongs to no table in // this cache's `table_columns`, so it must not leak. use crate::dsl::types::Type; - let mut cache = schema_with_table( - "Customers", - &[("id", Type::Int), ("Email", Type::Text)], - ); + let mut cache = schema_with_table("Customers", &[("id", Type::Int), ("Email", Type::Text)]); cache.columns.push("OrderTotal".to_string()); - let cs = - cands_with("drop column from Customers: ", 28, &cache); + let cs = cands_with("drop column from Customers: ", 28, &cache); assert!(cs.contains(&"Email".to_string()), "got {cs:?}"); assert!(cs.contains(&"id".to_string()), "got {cs:?}"); assert!( @@ -2271,8 +2287,8 @@ mod tests { #[test] fn cursor_mid_keyword_replaces_only_the_partial_prefix() { - let comp = candidates_at_cursor("cre", 3, &SchemaCache::default()) - .expect("some completion"); + let comp = + candidates_at_cursor("cre", 3, &SchemaCache::default()).expect("some completion"); assert_eq!(comp.replaced_range, (0, 3)); assert_eq!(comp.partial_prefix, "cre"); assert_eq!(comp.candidates.len(), 1); @@ -2282,8 +2298,8 @@ mod tests { #[test] fn cursor_at_word_boundary_has_empty_partial_prefix() { - let comp = candidates_at_cursor("create ", 7, &SchemaCache::default()) - .expect("some completion"); + let comp = + candidates_at_cursor("create ", 7, &SchemaCache::default()).expect("some completion"); assert_eq!(comp.replaced_range, (7, 7)); assert_eq!(comp.partial_prefix, ""); } @@ -2517,8 +2533,8 @@ mod tests { // inside `Name`, and substituting any name there // produces a complete command. No useful "next after // name" hint. - let t = typing_name_at_cursor("add column to table T: Name (text)", 27) - .expect("should fire"); + let t = + typing_name_at_cursor("add column to table T: Name (text)", 27).expect("should fire"); assert_eq!(t.next_after_name, None); } @@ -2534,8 +2550,8 @@ mod tests { assert!(invalid_ident_at_cursor("show data Cust", 14, &cache).is_none()); // `show data Cust` plus a typo: `show data Custp`. No // table starts with "Custp" → invalid. - let invalid = invalid_ident_at_cursor("show data Custp", 15, &cache) - .expect("should be invalid"); + let invalid = + invalid_ident_at_cursor("show data Custp", 15, &cache).expect("should be invalid"); assert_eq!(invalid.range, (10, 15)); assert_eq!(invalid.found, "Custp"); assert_eq!(invalid.source, IdentSource::Tables); @@ -2600,7 +2616,11 @@ mod tests { !cs.iter().any(|c| c == "Existing" || c == "AlsoExisting"), "NewName slot must not surface schema candidates; got {cs:?}" ); - assert_eq!(cs, vec!["if".to_string()], "only the advanced IF NOT EXISTS keyword"); + assert_eq!( + cs, + vec!["if".to_string()], + "only the advanced IF NOT EXISTS keyword" + ); } fn keyword_cand(text: &str) -> Candidate { @@ -2791,8 +2811,10 @@ mod tests { let cands = candidates_at_cursor(input, input.len(), &cache) .expect("some completion") .candidates; - let count_entries: Vec<_> = - cands.iter().filter(|c| c.text.eq_ignore_ascii_case("count")).collect(); + let count_entries: Vec<_> = cands + .iter() + .filter(|c| c.text.eq_ignore_ascii_case("count")) + .collect(); assert_eq!( count_entries.len(), 1, @@ -2805,7 +2827,9 @@ mod tests { ); // A non-colliding function at the same slot is unaffected. assert!( - cands.iter().any(|c| c.text == "coalesce" && c.kind == CandidateKind::Function), + cands + .iter() + .any(|c| c.text == "coalesce" && c.kind == CandidateKind::Function), "non-colliding functions still surface; got {cands:?}", ); } @@ -2875,8 +2899,10 @@ mod tests { let mut s = SchemaCache::default(); s.tables.push("OrderLines".into()); s.columns.push("count".into()); - s.table_columns - .insert("OrderLines".into(), vec![TableColumn::new("count", Type::Int)]); + s.table_columns.insert( + "OrderLines".into(), + vec![TableColumn::new("count", Type::Int)], + ); let input = "select sum(ol.count) from OrderLines ol"; let cursor = input.find("ol.count").unwrap() + 2; // right after `ol` assert!( @@ -2938,15 +2964,35 @@ mod tests { s.table_columns.insert( "a".to_string(), vec![ - TableColumn { name: "id".to_string(), user_type: Type::Int, not_null: false, has_default: false }, - TableColumn { name: "name".to_string(), user_type: Type::Text, not_null: false, has_default: false }, + TableColumn { + name: "id".to_string(), + user_type: Type::Int, + not_null: false, + has_default: false, + }, + TableColumn { + name: "name".to_string(), + user_type: Type::Text, + not_null: false, + has_default: false, + }, ], ); s.table_columns.insert( "b".to_string(), vec![ - TableColumn { name: "id".to_string(), user_type: Type::Int, not_null: false, has_default: false }, - TableColumn { name: "total".to_string(), user_type: Type::Real, not_null: false, has_default: false }, + TableColumn { + name: "id".to_string(), + user_type: Type::Int, + not_null: false, + has_default: false, + }, + TableColumn { + name: "total".to_string(), + user_type: Type::Real, + not_null: false, + has_default: false, + }, ], ); s @@ -3191,5 +3237,3 @@ mod tests { assert!(candidates_at_cursor_with("create ", 7, &cache, empty_ranker).is_none()); } } - - diff --git a/src/db.rs b/src/db.rs index 80ebb64..a230b55 100644 --- a/src/db.rs +++ b/src/db.rs @@ -30,24 +30,23 @@ use rusqlite::Connection; use tokio::sync::{mpsc, oneshot}; use tracing::{debug, info, warn}; +use crate::dsl::ColumnSpec; use crate::dsl::action::ReferentialAction; use crate::dsl::command::{ - ChangeColumnMode, Command, CompareOp, Constraint, ConstraintKind, Expr, IndexSelector, - Operand, Predicate, RelationshipSelector, RowFilter, SeedOverride, SeedOverrideKind, - SqlForeignKey, + ChangeColumnMode, Command, CompareOp, Constraint, ConstraintKind, Expr, IndexSelector, Operand, + Predicate, RelationshipSelector, RowFilter, SeedOverride, SeedOverrideKind, SqlForeignKey, }; -use crate::dsl::ColumnSpec; use crate::dsl::shortid; use crate::dsl::types::Type; use crate::dsl::value::{Bound, Value, ValueError}; use crate::mode::Mode; use crate::output_render::{Alignment, render_diagnostic_table}; -use crate::type_change; use crate::persistence::{ CellValue, ColumnSchema, IndexSchema, Persistence, PersistenceError, RelationshipSchema, SchemaSnapshot, TableCheck, TableSchema, TableSnapshot, decode_cell, parse_csv, parse_schema, }; use crate::project::{DATA_DIR, PROJECT_YAML}; +use crate::type_change; use crate::undo::{DEFAULT_RING_CAPACITY, SnapshotError, SnapshotMeta, SnapshotStore, Staged}; /// Inbox capacity. The worker is fast enough that this rarely @@ -120,8 +119,7 @@ pub struct IndexInfo { /// referenced) sides; the field meanings flip per side. /// `(outbound, inbound)` pair returned by /// [`Database::read_relationships`]. -pub type RelationshipsReply = - Result<(Vec, Vec), DbError>; +pub type RelationshipsReply = Result<(Vec, Vec), DbError>; #[derive(Debug, Clone, PartialEq, Eq)] pub struct RelationshipEnd { @@ -193,18 +191,15 @@ pub enum DbError { impl std::fmt::Display for DbError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - Self::Sqlite { message, .. } => f.write_str(&crate::t!( - "db.error.sqlite", - message = message, - )), - Self::Unsupported(detail) => f.write_str(&crate::t!( - "db.error.unsupported", - detail = detail, - )), - Self::InvalidValue(detail) => f.write_str(&crate::t!( - "db.error.invalid_value", - detail = detail, - )), + Self::Sqlite { message, .. } => { + f.write_str(&crate::t!("db.error.sqlite", message = message,)) + } + Self::Unsupported(detail) => { + f.write_str(&crate::t!("db.error.unsupported", detail = detail,)) + } + Self::InvalidValue(detail) => { + f.write_str(&crate::t!("db.error.invalid_value", detail = detail,)) + } Self::PersistenceFatal { operation, path, @@ -228,10 +223,7 @@ impl std::fmt::Display for DbError { detail = detail, )), Self::WorkerGone => f.write_str(&crate::t!("db.error.worker_gone")), - Self::Io(detail) => f.write_str(&crate::t!( - "db.error.io", - detail = detail, - )), + Self::Io(detail) => f.write_str(&crate::t!("db.error.io", detail = detail,)), } } } @@ -465,7 +457,10 @@ impl DbError { /// surfaces these as fatal banners. #[must_use] pub const fn is_fatal(&self) -> bool { - matches!(self, Self::PersistenceFatal { .. } | Self::RebuildRowFailed { .. }) + matches!( + self, + Self::PersistenceFatal { .. } | Self::RebuildRowFailed { .. } + ) } } @@ -902,9 +897,7 @@ enum Request { }, /// Close a batch: finalise the boundary snapshot into the ring if /// any mutation committed during the batch, else discard it. - EndBatch { - reply: oneshot::Sender<()>, - }, + EndBatch { reply: oneshot::Sender<()> }, /// Record the current input mode and persist it to /// `project.yaml` (ADR-0015 mode-restore amendment, issue /// #14). Sent at boot / project-switch to seed the mode and @@ -1043,7 +1036,12 @@ impl Database { pub async fn drop_table(&self, name: String, source: Option) -> Result<(), DbError> { let (reply, recv) = oneshot::channel(); - self.send(Request::DropTable { name, source, reply }).await?; + self.send(Request::DropTable { + name, + source, + reply, + }) + .await?; recv.await.map_err(|_| DbError::WorkerGone)? } @@ -1758,10 +1756,7 @@ impl Database { /// `EXPLAIN QUERY PLAN` only inspects how the engine would /// locate the rows — so this is safe even for `update` / /// `delete`. - pub async fn explain_query_plan( - &self, - query: Command, - ) -> Result { + pub async fn explain_query_plan(&self, query: Command) -> Result { let (reply, recv) = oneshot::channel(); self.send(Request::ExplainPlan { query, reply }).await?; recv.await.map_err(|_| DbError::WorkerGone)? @@ -1770,12 +1765,10 @@ impl Database { /// Read both directions of FK relationships for `table`. /// Used by the runtime's friendly-error enrichment to /// resolve parent / child table names (ADR-0019 §6). - pub async fn read_relationships( - &self, - table: String, - ) -> RelationshipsReply { + pub async fn read_relationships(&self, table: String) -> RelationshipsReply { let (reply, recv) = oneshot::channel(); - self.send(Request::ReadRelationships { table, reply }).await?; + self.send(Request::ReadRelationships { table, reply }) + .await?; recv.await.map_err(|_| DbError::WorkerGone)? } @@ -2215,16 +2208,18 @@ fn handle_request( source, reply, } => { - snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_create_table( - conn, - persistence, - &name, - &columns, - &primary_key, - &[], - &[], - &[], - )); + snapshot_then(snap, batch, conn, source.as_deref(), reply, || { + do_create_table( + conn, + persistence, + &name, + &columns, + &primary_key, + &[], + &[], + &[], + ) + }); } Request::SqlCreateTable { name, @@ -2290,8 +2285,7 @@ fn handle_request( let _ = reply.send(result); } else { snapshot_then(snap, batch, conn, source.as_deref(), reply, || { - do_drop_table(conn, persistence, &name) - .map(|()| DropOutcome::Dropped) + do_drop_table(conn, persistence, &name).map(|()| DropOutcome::Dropped) }); } } @@ -2301,12 +2295,9 @@ fn handle_request( source, reply, } => { - snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_add_column( - conn, - persistence, - &table, - &column, - )); + snapshot_then(snap, batch, conn, source.as_deref(), reply, || { + do_add_column(conn, persistence, &table, &column) + }); } Request::DropColumn { table, @@ -2315,13 +2306,9 @@ fn handle_request( source, reply, } => { - snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_drop_column( - conn, - persistence, - &table, - &column, - cascade, - )); + snapshot_then(snap, batch, conn, source.as_deref(), reply, || { + do_drop_column(conn, persistence, &table, &column, cascade) + }); } Request::RenameColumn { table, @@ -2330,13 +2317,9 @@ fn handle_request( source, reply, } => { - snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_rename_column( - conn, - persistence, - &table, - &old, - &new, - )); + snapshot_then(snap, batch, conn, source.as_deref(), reply, || { + do_rename_column(conn, persistence, &table, &old, &new) + }); } Request::RenameTable { table, @@ -2344,12 +2327,9 @@ fn handle_request( source, reply, } => { - snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_rename_table( - conn, - persistence, - &table, - &new, - )); + snapshot_then(snap, batch, conn, source.as_deref(), reply, || { + do_rename_table(conn, persistence, &table, &new) + }); } Request::ChangeColumnType { table, @@ -2359,14 +2339,9 @@ fn handle_request( source, reply, } => { - snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_change_column_type( - conn, - persistence, - &table, - &column, - ty, - mode, - )); + snapshot_then(snap, batch, conn, source.as_deref(), reply, || { + do_change_column_type(conn, persistence, &table, &column, ty, mode) + }); } Request::ListTables { reply } => { let _ = reply.send(do_list_tables(conn)); @@ -2392,18 +2367,20 @@ fn handle_request( source, reply, } => { - snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_add_relationship( - conn, - persistence, - name.as_deref(), - &parent_table, - &parent_columns, - &child_table, - &child_columns, - on_delete, - on_update, - create_fk, - )); + snapshot_then(snap, batch, conn, source.as_deref(), reply, || { + do_add_relationship( + conn, + persistence, + name.as_deref(), + &parent_table, + &parent_columns, + &child_table, + &child_columns, + on_delete, + on_update, + create_fk, + ) + }); } Request::CreateM2nRelationship { t1, @@ -2413,13 +2390,7 @@ fn handle_request( reply, } => { snapshot_then(snap, batch, conn, source.as_deref(), reply, || { - do_create_m2n_relationship( - conn, - persistence, - &t1, - &t2, - name.as_deref(), - ) + do_create_m2n_relationship(conn, persistence, &t1, &t2, name.as_deref()) }); } Request::DropRelationship { @@ -2427,11 +2398,9 @@ fn handle_request( source, reply, } => { - snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_drop_relationship( - conn, - persistence, - &selector, - )); + snapshot_then(snap, batch, conn, source.as_deref(), reply, || { + do_drop_relationship(conn, persistence, &selector) + }); } Request::AddIndex { name, @@ -2440,28 +2409,28 @@ fn handle_request( source, reply, } => { - snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_add_index( - conn, - persistence, - name.as_deref(), - &table, - &columns, - // Simple-mode `add index` is always non-unique - // (ADR-0025); `add unique index` stays deferred. The SQL - // `CREATE UNIQUE INDEX` path passes `true` (ADR-0035 §4d). - false, - )); + snapshot_then(snap, batch, conn, source.as_deref(), reply, || { + do_add_index( + conn, + persistence, + name.as_deref(), + &table, + &columns, + // Simple-mode `add index` is always non-unique + // (ADR-0025); `add unique index` stays deferred. The SQL + // `CREATE UNIQUE INDEX` path passes `true` (ADR-0035 §4d). + false, + ) + }); } Request::DropIndex { selector, source, reply, } => { - snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_drop_index( - conn, - persistence, - &selector, - )); + snapshot_then(snap, batch, conn, source.as_deref(), reply, || { + do_drop_index(conn, persistence, &selector) + }); } Request::SqlDropIndex { name, @@ -2476,8 +2445,7 @@ fn handle_request( // `do_drop_index` (`sql IS NOT NULL`). if if_exists && !index_exists(conn, &name, true).unwrap_or(false) { // ADR-0052: journaling moved to the dispatch layer. - let result: Result = - Ok(DropIndexOutcome::Skipped); + let result: Result = Ok(DropIndexOutcome::Skipped); let _ = reply.send(result); } else { snapshot_then(snap, batch, conn, source.as_deref(), reply, || { @@ -2513,15 +2481,8 @@ fn handle_request( let _ = reply.send(result); } else { snapshot_then(snap, batch, conn, source.as_deref(), reply, || { - do_add_index( - conn, - persistence, - name.as_deref(), - &table, - &columns, - unique, - ) - .map(CreateIndexOutcome::Created) + do_add_index(conn, persistence, name.as_deref(), &table, &columns, unique) + .map(CreateIndexOutcome::Created) }); } } @@ -2532,13 +2493,9 @@ fn handle_request( source, reply, } => { - snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_add_constraint( - conn, - persistence, - &table, - &column, - &constraint, - )); + snapshot_then(snap, batch, conn, source.as_deref(), reply, || { + do_add_constraint(conn, persistence, &table, &column, &constraint) + }); } Request::DropConstraint { table, @@ -2547,13 +2504,9 @@ fn handle_request( source, reply, } => { - snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_drop_constraint( - conn, - persistence, - &table, - &column, - kind, - )); + snapshot_then(snap, batch, conn, source.as_deref(), reply, || { + do_drop_constraint(conn, persistence, &table, &column, kind) + }); } Request::SetColumnDefault { table, @@ -2563,13 +2516,7 @@ fn handle_request( reply, } => { snapshot_then(snap, batch, conn, source.as_deref(), reply, || { - do_set_column_default( - conn, - persistence, - &table, - &column, - &default_sql, - ) + do_set_column_default(conn, persistence, &table, &column, &default_sql) }); } Request::AlterAddTableCheck { @@ -2580,13 +2527,7 @@ fn handle_request( reply, } => { snapshot_then(snap, batch, conn, source.as_deref(), reply, || { - do_alter_add_table_check( - conn, - persistence, - &table, - name.as_deref(), - &expr_sql, - ) + do_alter_add_table_check(conn, persistence, &table, name.as_deref(), &expr_sql) }); } Request::AlterAddUnique { @@ -2617,13 +2558,7 @@ fn handle_request( reply, } => { snapshot_then(snap, batch, conn, source.as_deref(), reply, || { - do_alter_add_foreign_key( - conn, - persistence, - &child_table, - name.as_deref(), - &fk, - ) + do_alter_add_foreign_key(conn, persistence, &child_table, name.as_deref(), &fk) }); } Request::Insert { @@ -2633,13 +2568,9 @@ fn handle_request( source, reply, } => { - snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_insert( - conn, - persistence, - &table, - columns.as_deref(), - &values, - )); + snapshot_then(snap, batch, conn, source.as_deref(), reply, || { + do_insert(conn, persistence, &table, columns.as_deref(), &values) + }); } Request::Seed { table, @@ -2652,15 +2583,17 @@ fn handle_request( } => { // One snapshot wraps the whole seed (ADR-0048 D15 — one undo // step), exactly like a single insert. - snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_seed( - conn, - persistence, - &table, - target_column.as_deref(), - count, - &overrides, - rng_seed, - )); + snapshot_then(snap, batch, conn, source.as_deref(), reply, || { + do_seed( + conn, + persistence, + &table, + target_column.as_deref(), + count, + &overrides, + rng_seed, + ) + }); } Request::Update { table, @@ -2669,13 +2602,9 @@ fn handle_request( source, reply, } => { - snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_update( - conn, - persistence, - &table, - &assignments, - &filter, - )); + snapshot_then(snap, batch, conn, source.as_deref(), reply, || { + do_update(conn, persistence, &table, &assignments, &filter) + }); } Request::Delete { table, @@ -2683,12 +2612,9 @@ fn handle_request( source, reply, } => { - snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_delete( - conn, - persistence, - &table, - &filter, - )); + snapshot_then(snap, batch, conn, source.as_deref(), reply, || { + do_delete(conn, persistence, &table, &filter) + }); } Request::QueryData { table, @@ -2711,16 +2637,18 @@ fn handle_request( literal_rows, reply, } => { - snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_sql_insert( - conn, - persistence, - &sql, - &target_table, - &listed_columns, - &row_source, - returning, - &literal_rows, - )); + snapshot_then(snap, batch, conn, source.as_deref(), reply, || { + do_sql_insert( + conn, + persistence, + &sql, + &target_table, + &listed_columns, + &row_source, + returning, + &literal_rows, + ) + }); } Request::RunSqlUpdate { sql, @@ -2730,14 +2658,16 @@ fn handle_request( set_literals, reply, } => { - snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_sql_update( - conn, - persistence, - &sql, - &target_table, - returning, - &set_literals, - )); + snapshot_then(snap, batch, conn, source.as_deref(), reply, || { + do_sql_update( + conn, + persistence, + &sql, + &target_table, + returning, + &set_literals, + ) + }); } Request::RunSqlDelete { sql, @@ -2746,13 +2676,9 @@ fn handle_request( returning, reply, } => { - snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_sql_delete( - conn, - persistence, - &sql, - &target_table, - returning, - )); + snapshot_then(snap, batch, conn, source.as_deref(), reply, || { + do_sql_delete(conn, persistence, &sql, &target_table, returning) + }); } Request::RebuildFromText { project_path, @@ -2835,9 +2761,7 @@ fn do_list_names_for( } IdentSource::Relationships => { let mut stmt = conn - .prepare(&format!( - "SELECT name FROM {REL_TABLE} ORDER BY name;" - )) + .prepare(&format!("SELECT name FROM {REL_TABLE} ORDER BY name;")) .map_err(DbError::from_rusqlite)?; let rows = stmt .query_map([], |row| row.get::<_, String>(0)) @@ -2869,10 +2793,9 @@ fn do_list_names_for( } Ok(out) } - IdentSource::NewName - | IdentSource::Types - | IdentSource::Generators - | IdentSource::Free => Ok(Vec::new()), + IdentSource::NewName | IdentSource::Types | IdentSource::Generators | IdentSource::Free => { + Ok(Vec::new()) + } } } @@ -2916,8 +2839,7 @@ fn do_find_rows_matching( .map_err(|e| DbError::InvalidValue(e.to_string()))?; let column_names: Vec = schema.columns.iter().map(|c| c.name.clone()).collect(); - let column_types: Vec> = - schema.columns.iter().map(|c| c.user_type).collect(); + let column_types: Vec> = schema.columns.iter().map(|c| c.user_type).collect(); let cols_csv = column_names .iter() .map(|c| quote_ident(c)) @@ -2935,8 +2857,7 @@ fn do_find_rows_matching( let mut stmt = conn.prepare(&sql).map_err(DbError::from_rusqlite)?; let rows_iter = stmt .query_map(rusqlite::params![bound_value], |row| { - let mut cells: Vec = - Vec::with_capacity(column_names.len()); + let mut cells: Vec = Vec::with_capacity(column_names.len()); for i in 0..column_names.len() { cells.push(row.get(i)?); } @@ -3139,10 +3060,7 @@ fn read_project_created_at(conn: &Connection) -> Result { /// Read a single table's full row data, returning `None` if /// the table no longer exists (e.g. a recent `drop_table`). -fn read_table_snapshot( - conn: &Connection, - table: &str, -) -> Result, DbError> { +fn read_table_snapshot(conn: &Connection, table: &str) -> Result, DbError> { if !user_table_exists(conn, table)? { return Ok(None); } @@ -3159,11 +3077,7 @@ fn read_table_snapshot( check: c.check.clone(), }) .collect(); - let column_idents: Vec = read - .columns - .iter() - .map(|c| quote_ident(&c.name)) - .collect(); + let column_idents: Vec = read.columns.iter().map(|c| quote_ident(&c.name)).collect(); let sql = format!( "SELECT {} FROM {} ORDER BY rowid", column_idents.join(", "), @@ -3234,7 +3148,9 @@ fn canonical_table_name(conn: &Connection, name: &str) -> Result, .map_err(DbError::from_rusqlite)?; let mut rows = stmt.query([name]).map_err(DbError::from_rusqlite)?; match rows.next().map_err(DbError::from_rusqlite)? { - Some(row) => Ok(Some(row.get::<_, String>(0).map_err(DbError::from_rusqlite)?)), + Some(row) => Ok(Some( + row.get::<_, String>(0).map_err(DbError::from_rusqlite)?, + )), None => Ok(None), } } @@ -3252,9 +3168,7 @@ fn row_value_to_cell(row: &rusqlite::Row<'_>, idx: usize) -> Result CellValue::Null, ValueRef::Integer(n) => CellValue::Integer(n), ValueRef::Real(f) => CellValue::Real(f), - ValueRef::Text(bytes) => { - CellValue::Text(String::from_utf8_lossy(bytes).into_owned()) - } + ValueRef::Text(bytes) => CellValue::Text(String::from_utf8_lossy(bytes).into_owned()), ValueRef::Blob(bytes) => CellValue::Blob(bytes.to_vec()), }) } @@ -3496,10 +3410,9 @@ fn do_create_table( // computes the next value itself (verified by the multi-column / // rebuild round-trip tests) — so the choice is purely about // matching the rebuild generator (ADR-0035 §6.4). - let inline_pk_col: Option<&str> = (primary_key.len() == 1 - && !columns.is_empty() - && primary_key[0] == columns[0].name) - .then(|| primary_key[0].as_str()); + let inline_pk_col: Option<&str> = + (primary_key.len() == 1 && !columns.is_empty() && primary_key[0] == columns[0].name) + .then(|| primary_key[0].as_str()); // Compile each column's CHECK once (ADR-0029 §4) — reused // by the DDL clause and the metadata insert below. The @@ -3511,9 +3424,11 @@ fn do_create_table( let check_sqls: Vec> = columns .iter() .map(|c| { - c.check_sql - .clone() - .or_else(|| c.check.as_ref().map(|e| compile_check_sql(e, &check_schema))) + c.check_sql.clone().or_else(|| { + c.check + .as_ref() + .map(|e| compile_check_sql(e, &check_schema)) + }) }) .collect(); @@ -3865,57 +3780,55 @@ fn do_add_auto_generated_column( let old_columns: Vec = old_schema.columns.iter().map(|c| c.name.clone()).collect(); let new_columns: Vec = new_schema.columns.iter().map(|c| c.name.clone()).collect(); - let copy_data = |tx: &rusqlite::Transaction<'_>, - temp_name: &str, - orig: &str| - -> Result<(), DbError> { - if row_count == 0 { - return Ok(()); - } - // Read all rows from old, append the auto-fill value, - // INSERT into temp. - let select_cols = old_columns - .iter() - .map(|c| quote_ident(c)) - .collect::>() - .join(", "); - let select_sql = format!( - "SELECT {select_cols} FROM {orig};", - orig = quote_ident(orig), - ); - let cols_csv = new_columns - .iter() - .map(|c| quote_ident(c)) - .collect::>() - .join(", "); - let placeholders = (1..=new_columns.len()) - .map(|i| format!("?{i}")) - .collect::>() - .join(", "); - let insert_sql = format!( - "INSERT INTO {temp} ({cols_csv}) VALUES ({placeholders});", - temp = quote_ident(temp_name), - ); - - let mut select_stmt = tx.prepare(&select_sql).map_err(DbError::from_rusqlite)?; - let mut rows = select_stmt.query([]).map_err(DbError::from_rusqlite)?; - let mut insert_stmt = tx.prepare(&insert_sql).map_err(DbError::from_rusqlite)?; - let mut row_idx = 0usize; - while let Some(r) = rows.next().map_err(DbError::from_rusqlite)? { - let mut values: Vec = Vec::with_capacity(new_columns.len()); - for i in 0..old_columns.len() { - values.push(r.get(i).map_err(DbError::from_rusqlite)?); + let copy_data = + |tx: &rusqlite::Transaction<'_>, temp_name: &str, orig: &str| -> Result<(), DbError> { + if row_count == 0 { + return Ok(()); } - values.push(auto_fill_values[row_idx].clone()); - let params: Vec<&dyn rusqlite::ToSql> = - values.iter().map(|v| v as &dyn rusqlite::ToSql).collect(); - insert_stmt - .execute(rusqlite::params_from_iter(params)) - .map_err(DbError::from_rusqlite)?; - row_idx += 1; - } - Ok(()) - }; + // Read all rows from old, append the auto-fill value, + // INSERT into temp. + let select_cols = old_columns + .iter() + .map(|c| quote_ident(c)) + .collect::>() + .join(", "); + let select_sql = format!( + "SELECT {select_cols} FROM {orig};", + orig = quote_ident(orig), + ); + let cols_csv = new_columns + .iter() + .map(|c| quote_ident(c)) + .collect::>() + .join(", "); + let placeholders = (1..=new_columns.len()) + .map(|i| format!("?{i}")) + .collect::>() + .join(", "); + let insert_sql = format!( + "INSERT INTO {temp} ({cols_csv}) VALUES ({placeholders});", + temp = quote_ident(temp_name), + ); + + let mut select_stmt = tx.prepare(&select_sql).map_err(DbError::from_rusqlite)?; + let mut rows = select_stmt.query([]).map_err(DbError::from_rusqlite)?; + let mut insert_stmt = tx.prepare(&insert_sql).map_err(DbError::from_rusqlite)?; + let mut row_idx = 0usize; + while let Some(r) = rows.next().map_err(DbError::from_rusqlite)? { + let mut values: Vec = Vec::with_capacity(new_columns.len()); + for i in 0..old_columns.len() { + values.push(r.get(i).map_err(DbError::from_rusqlite)?); + } + values.push(auto_fill_values[row_idx].clone()); + let params: Vec<&dyn rusqlite::ToSql> = + values.iter().map(|v| v as &dyn rusqlite::ToSql).collect(); + insert_stmt + .execute(rusqlite::params_from_iter(params)) + .map_err(DbError::from_rusqlite)?; + row_idx += 1; + } + Ok(()) + }; let metadata_updates = |tx: &rusqlite::Transaction<'_>| -> Result<(), DbError> { tx.execute( @@ -4015,10 +3928,11 @@ fn do_add_constrained_column_via_rebuild( // The CHECK: the raw advanced-mode text (`check_sql`) wins; otherwise // the typed `check` AST is compiled against the post-add schema (so // it may reference the new column itself). - let check_sql = spec - .check_sql - .clone() - .or_else(|| spec.check.as_ref().map(|e| compile_check_sql(e, &new_schema))); + let check_sql = spec.check_sql.clone().or_else(|| { + spec.check + .as_ref() + .map(|e| compile_check_sql(e, &new_schema)) + }); if let Some(last) = new_schema.columns.last_mut() { last.check.clone_from(&check_sql); } @@ -4100,9 +4014,7 @@ fn do_add_constraint( // ADR-0029 §6 — an auto-generated column fills its own // values, so a `default` would be a second, ambiguous // source of "the value when none is given". - Constraint::Default(_) - if matches!(col_user_type, Some(Type::Serial | Type::ShortId)) => - { + Constraint::Default(_) if matches!(col_user_type, Some(Type::Serial | Type::ShortId)) => { return Err(DbError::Unsupported(format!( "`{table}.{column}` is a {ty} column — it auto-fills its own \ values, so it cannot also carry a `default`.", @@ -4130,7 +4042,9 @@ fn do_add_constraint( &old_schema, table, column, - check_sql.as_deref().expect("check_sql set for a Check constraint"), + check_sql + .as_deref() + .expect("check_sql set for a Check constraint"), )?, Constraint::Default(_) => None, }; @@ -4374,8 +4288,7 @@ fn read_constraint_dry_run_rows( ) -> Result, DbError> { use rusqlite::types::Value as RV; let pk_columns = &schema.primary_key; - let mut select_idents: Vec = - pk_columns.iter().map(|c| quote_ident(c)).collect(); + let mut select_idents: Vec = pk_columns.iter().map(|c| quote_ident(c)).collect(); select_idents.push(quote_ident(column)); let sql = format!( "SELECT {cols} FROM {tbl} WHERE {pred};", @@ -4582,13 +4495,8 @@ fn dry_run_check( column: &str, check_sql: &str, ) -> Result, DbError> { - let rows = read_constraint_dry_run_rows( - conn, - schema, - table, - column, - &format!("NOT ({check_sql})"), - )?; + let rows = + read_constraint_dry_run_rows(conn, schema, table, column, &format!("NOT ({check_sql})"))?; if rows.is_empty() { return Ok(None); } @@ -4818,9 +4726,7 @@ fn do_drop_column( debug!(ddl = %ddl, "drop_column"); tx.execute_batch(&ddl).map_err(DbError::from_rusqlite)?; tx.execute( - &format!( - "DELETE FROM {META_TABLE} WHERE table_name = ?1 AND column_name = ?2;" - ), + &format!("DELETE FROM {META_TABLE} WHERE table_name = ?1 AND column_name = ?2;"), [table, column], ) .map_err(DbError::from_rusqlite)?; @@ -5055,7 +4961,9 @@ fn do_rename_table( )) .map_err(DbError::from_rusqlite)?; let rows = stmt - .query_map([new], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))) + .query_map([new], |r| { + Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)) + }) .map_err(DbError::from_rusqlite)?; let mut v = Vec::new(); for row in rows { @@ -5284,8 +5192,8 @@ fn do_change_column_type( // STRICT typing will accept or refuse cells; we wrap // any error in a friendly message (ADR-0017 §5, // ADR-0002 user-facing posture). - rebuild_table(conn, table, &old_schema, &new_schema, metadata_updates) - .map_err(|e| { + rebuild_table(conn, table, &old_schema, &new_schema, metadata_updates).map_err( + |e| { let ctx = crate::friendly::TranslateContext { operation: Some(crate::friendly::Operation::ChangeColumnType), table: Some(table.to_string()), @@ -5294,14 +5202,14 @@ fn do_change_column_type( target_type: Some(ty), ..crate::friendly::TranslateContext::default() }; - let rendered = - crate::friendly::translate_error(&e, &ctx).render(); + let rendered = crate::friendly::translate_error(&e, &ctx).render(); DbError::Unsupported(rendered) - })?; + }, + )?; None } - ChangeColumnMode::Default | ChangeColumnMode::ForceConversion => Some( - run_change_column_with_dry_run( + ChangeColumnMode::Default | ChangeColumnMode::ForceConversion => { + Some(run_change_column_with_dry_run( conn, table, column, @@ -5311,8 +5219,8 @@ fn do_change_column_type( &old_schema, &new_schema, metadata_updates, - )?, - ), + )?) + } }; // Client-side note fires when at least one cell was // materially transformed (ADR-0017 §6) OR at least one @@ -5378,7 +5286,12 @@ where .expect("column existence checked above"); let pk_indices: Vec = pk_columns .iter() - .map(|pk| all_columns.iter().position(|c| c == pk).expect("pk in cols")) + .map(|pk| { + all_columns + .iter() + .position(|c| c == pk) + .expect("pk in cols") + }) .collect(); let select_cols = all_columns @@ -5425,7 +5338,10 @@ where // Placeholder; replaced after the loop. (CellOutcome::Clean(RV::Null), true) } else { - (type_change::transform_cell(src_ty, target_ty, &original), false) + ( + type_change::transform_cell(src_ty, target_ty, &original), + false, + ) }; outcomes.push(Outcome { pk_values, @@ -5516,39 +5432,37 @@ where }) .collect(); - let copy_data = |tx: &rusqlite::Transaction<'_>, - temp_name: &str, - _orig: &str| - -> Result<(), DbError> { - if rows.is_empty() { - return Ok(()); - } - let cols_csv = all_columns - .iter() - .map(|c| quote_ident(c)) - .collect::>() - .join(", "); - let placeholders = (0..all_columns.len()) - .map(|i| format!("?{}", i + 1)) - .collect::>() - .join(", "); - let insert_sql = format!( - "INSERT INTO {temp} ({cols}) VALUES ({ph});", - temp = quote_ident(temp_name), - cols = cols_csv, - ph = placeholders, - ); - let mut stmt = tx.prepare(&insert_sql).map_err(DbError::from_rusqlite)?; - for (row_idx, row) in rows.iter().enumerate() { - let mut bound: Vec = row.clone(); - bound[target_idx] = transformed_values[row_idx].clone(); - let params: Vec<&dyn rusqlite::ToSql> = - bound.iter().map(|v| v as &dyn rusqlite::ToSql).collect(); - stmt.execute(rusqlite::params_from_iter(params)) - .map_err(DbError::from_rusqlite)?; - } - Ok(()) - }; + let copy_data = + |tx: &rusqlite::Transaction<'_>, temp_name: &str, _orig: &str| -> Result<(), DbError> { + if rows.is_empty() { + return Ok(()); + } + let cols_csv = all_columns + .iter() + .map(|c| quote_ident(c)) + .collect::>() + .join(", "); + let placeholders = (0..all_columns.len()) + .map(|i| format!("?{}", i + 1)) + .collect::>() + .join(", "); + let insert_sql = format!( + "INSERT INTO {temp} ({cols}) VALUES ({ph});", + temp = quote_ident(temp_name), + cols = cols_csv, + ph = placeholders, + ); + let mut stmt = tx.prepare(&insert_sql).map_err(DbError::from_rusqlite)?; + for (row_idx, row) in rows.iter().enumerate() { + let mut bound: Vec = row.clone(); + bound[target_idx] = transformed_values[row_idx].clone(); + let params: Vec<&dyn rusqlite::ToSql> = + bound.iter().map(|v| v as &dyn rusqlite::ToSql).collect(); + stmt.execute(rusqlite::params_from_iter(params)) + .map_err(DbError::from_rusqlite)?; + } + Ok(()) + }; rebuild_table_with_copy(conn, table, new_schema, copy_data, metadata_updates)?; @@ -5601,10 +5515,7 @@ where /// `MAX(non-null value) + 1`. For `ShortId` targets: generate /// fresh shortids that don't collide with existing values or /// with one another (5-retry budget per cell, ADR-0018 §3). -fn fill_auto_generated_cells( - target_ty: Type, - outcomes: &mut [Outcome], -) -> Result<(), DbError> { +fn fill_auto_generated_cells(target_ty: Type, outcomes: &mut [Outcome]) -> Result<(), DbError> { use rusqlite::types::Value as RV; use type_change::CellOutcome; @@ -5644,11 +5555,7 @@ fn fill_auto_generated_cells( .collect(); let auto_fill_count = outcomes.iter().filter(|o| o.is_auto_fill).count(); let new_values = generate_shortid_batch(auto_fill_count, &existing)?; - for (idx, o) in outcomes - .iter_mut() - .filter(|o| o.is_auto_fill) - .enumerate() - { + for (idx, o) in outcomes.iter_mut().filter(|o| o.is_auto_fill).enumerate() { o.outcome = CellOutcome::Clean(new_values[idx].clone()); } } @@ -5695,9 +5602,7 @@ fn render_lossy_diagnostic( for o in lossies.iter().take(visible) { let mut cells = pk_value_cells(&o.pk_values); let (new_str, reason) = match &o.outcome { - type_change::CellOutcome::Lossy { new, reason } => { - (render_value(new), reason.clone()) - } + type_change::CellOutcome::Lossy { new, reason } => (render_value(new), reason.clone()), _ => unreachable!("filtered to Lossy"), }; cells.push(render_value(&o.original)); @@ -5746,10 +5651,7 @@ fn render_incompatible_diagnostic( ]); let mut alignments = pk_header_alignments(pk_columns, old_schema); - alignments.extend([ - type_change::is_in_matrix_alignment(src_ty), - Alignment::Left, - ]); + alignments.extend([type_change::is_in_matrix_alignment(src_ty), Alignment::Left]); let total = incompatibles.len(); let visible = total.min(DIAGNOSTIC_ROW_CAP); @@ -5908,10 +5810,7 @@ struct Outcome { } fn pk_header_cells(pk_columns: &[String]) -> Vec { - pk_columns - .iter() - .map(|c| format!("{c} (PK)")) - .collect() + pk_columns.iter().map(|c| format!("{c} (PK)")).collect() } fn pk_header_alignments(pk_columns: &[String], schema: &ReadSchema) -> Vec { @@ -6382,7 +6281,9 @@ fn read_table_checks(conn: &Connection, table: &str) -> Result, fn check_table_has_name_column(conn: &Connection) -> Result { let count: i64 = conn .query_row( - &format!("SELECT COUNT(*) FROM pragma_table_info('{CHECK_TABLE}') WHERE name = 'name';"), + &format!( + "SELECT COUNT(*) FROM pragma_table_info('{CHECK_TABLE}') WHERE name = 'name';" + ), [], |row| row.get(0), ) @@ -6544,14 +6445,23 @@ mod rewrite_check_table_qualifier_tests { #[test] fn rewrites_a_bare_qualifier() { - assert_eq!(rewrite_check_table_qualifier("T.age > 0", "T", "U"), "U.age > 0"); + assert_eq!( + rewrite_check_table_qualifier("T.age > 0", "T", "U"), + "U.age > 0" + ); // multiple occurrences, table-level CHECK shape - assert_eq!(rewrite_check_table_qualifier("T.a <> T.b", "T", "U"), "U.a <> U.b"); + assert_eq!( + rewrite_check_table_qualifier("T.a <> T.b", "T", "U"), + "U.a <> U.b" + ); } #[test] fn is_case_insensitive_on_the_qualifier() { - assert_eq!(rewrite_check_table_qualifier("t.age > 0", "T", "U"), "U.age > 0"); + assert_eq!( + rewrite_check_table_qualifier("t.age > 0", "T", "U"), + "U.age > 0" + ); } #[test] @@ -6578,7 +6488,10 @@ mod rewrite_check_table_qualifier_tests { #[test] fn unqualified_check_is_a_no_op() { - assert_eq!(rewrite_check_table_qualifier("age > 0", "T", "U"), "age > 0"); + assert_eq!( + rewrite_check_table_qualifier("age > 0", "T", "U"), + "age > 0" + ); } #[test] @@ -6674,9 +6587,7 @@ fn read_table_indexes(conn: &Connection, table: &str) -> Result, /// The indexed columns of `index`, in index order. fn read_index_columns(conn: &Connection, index: &str) -> Result, DbError> { let mut stmt = conn - .prepare( - "SELECT name FROM pragma_index_info(?1) ORDER BY seqno;", - ) + .prepare("SELECT name FROM pragma_index_info(?1) ORDER BY seqno;") .map_err(DbError::from_rusqlite)?; let rows = stmt .query_map([index], |row| row.get::<_, String>(0)) @@ -6925,11 +6836,8 @@ where // column references stay valid. let captured_indexes = read_table_indexes(&tx, table)?; - tx.execute_batch(&format!( - "DROP TABLE {ident};", - ident = quote_ident(table) - )) - .map_err(DbError::from_rusqlite)?; + tx.execute_batch(&format!("DROP TABLE {ident};", ident = quote_ident(table))) + .map_err(DbError::from_rusqlite)?; tx.execute_batch(&format!( "ALTER TABLE {temp} RENAME TO {final_name};", temp = quote_ident(&temp_name), @@ -7275,7 +7183,10 @@ fn resolve_create_table_fks( if fk.parent_table == table_name { ( primary_key.to_vec(), - columns.iter().map(|c| (c.name.clone(), Some(c.ty))).collect(), + columns + .iter() + .map(|c| (c.name.clone(), Some(c.ty))) + .collect(), ) } else { let ps = read_schema(conn, &fk.parent_table)?; @@ -7625,7 +7536,15 @@ fn do_drop_relationship( FROM {REL_TABLE} WHERE name = ?1;" ), [name], - |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?)), + |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + )) + }, ) .ok(), RelationshipSelector::Endpoints { @@ -7642,7 +7561,15 @@ fn do_drop_relationship( AND child_table = ?3 AND child_column = ?4;" ), [parent_table, parent_column, child_table, child_column], - |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?)), + |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + )) + }, ) .ok(), }; @@ -7891,9 +7818,7 @@ fn do_drop_constraint_by_name( let (t, n) = (table.to_string(), name.to_string()); rebuild_table(conn, table, &old_schema, &new_schema, |tx| { tx.execute( - &format!( - "DELETE FROM {CHECK_TABLE} WHERE table_name = ?1 AND name = ?2;" - ), + &format!("DELETE FROM {CHECK_TABLE} WHERE table_name = ?1 AND name = ?2;"), [t.as_str(), n.as_str()], ) .map_err(DbError::from_rusqlite)?; @@ -8214,10 +8139,7 @@ fn do_drop_index( match matches.as_slice() { [] => { return Err(DbError::Sqlite { - message: format!( - "no index on {table} ({}) exists", - columns.join(", ") - ), + message: format!("no index on {table} ({}) exists", columns.join(", ")), kind: SqliteErrorKind::Other, }); } @@ -8298,11 +8220,7 @@ fn do_describe_table(conn: &Connection, name: &str) -> Result Result { +fn impl_value_for(schema: &ReadSchema, column: &str, value: &Value) -> Result { let col = schema .columns .iter() @@ -8542,8 +8460,7 @@ fn query_rows_by_rowid( ) -> Result { let schema = read_schema(conn, table)?; let column_names: Vec = schema.columns.iter().map(|c| c.name.clone()).collect(); - let column_types: Vec> = - schema.columns.iter().map(|c| c.user_type).collect(); + let column_types: Vec> = schema.columns.iter().map(|c| c.user_type).collect(); if rowids.is_empty() { return Ok(DataResult { @@ -8575,8 +8492,7 @@ fn query_rows_by_rowid( .collect(); let rows_iter = stmt .query_map(rusqlite::params_from_iter(params.iter()), |row| { - let mut cells: Vec = - Vec::with_capacity(column_names.len()); + let mut cells: Vec = Vec::with_capacity(column_names.len()); for i in 0..column_names.len() { cells.push(row.get(i)?); } @@ -8751,9 +8667,7 @@ fn do_seed( // Column-fill (D1 form 2) is a distinct UPDATE path. if let Some(col) = target_column { - return do_seed_column_fill( - conn, persistence, table, col, count, overrides, rng_seed, - ); + return do_seed_column_fill(conn, persistence, table, col, count, overrides, rng_seed); } let n = count.unwrap_or(DEFAULT_SEED_COUNT); @@ -8862,7 +8776,14 @@ fn do_seed( // up front rather than silently capped (DA finding). FK / type binding // still apply — a value that violates a constraint surfaces through the // existing FK-error guard. - apply_seed_overrides(&schema, overrides, n, &col_names, &mut plans, &mut advisory_columns)?; + apply_seed_overrides( + &schema, + overrides, + n, + &col_names, + &mut plans, + &mut advisory_columns, + )?; // Uniqueness groups (ADR-0048 D10): value tuples that must stay // distinct across the batch and against existing rows — the @@ -8987,8 +8908,7 @@ fn do_seed( let keys: Vec = unique_groups .iter() .map(|group| { - let projected: Vec = - group.iter().map(|&i| values[i].clone()).collect(); + let projected: Vec = group.iter().map(|&i| values[i].clone()).collect(); seed_value_list_key(&projected) }) .collect(); @@ -9003,8 +8923,7 @@ fn do_seed( for (gi, k) in keys.into_iter().enumerate() { used[gi].insert(k); } - let (_rows, rowid) = - insert_one_row(conn, table, &schema, Some(&col_names), &values)?; + let (_rows, rowid) = insert_one_row(conn, table, &schema, Some(&col_names), &values)?; break Some(rowid); }; match rowid { @@ -9132,8 +9051,7 @@ fn seed_override_capacity_guard( .iter() .find(|c| c.name.eq_ignore_ascii_case(column)) .is_some_and(|c| c.unique) - || (schema.primary_key.len() == 1 - && schema.primary_key[0].eq_ignore_ascii_case(column)); + || (schema.primary_key.len() == 1 && schema.primary_key[0].eq_ignore_ascii_case(column)); if single_unique { return Err(DbError::Unsupported(format!( "cannot fill {row_count} rows: `set {column} …` offers only {distinct} distinct \ @@ -9391,9 +9309,7 @@ fn do_seed_column_fill( ); let mut used: std::collections::HashSet = std::collections::HashSet::new(); if enforce_unique { - for tuple in - sample_parent_key_tuples(conn, table, std::slice::from_ref(&canonical_col))? - { + for tuple in sample_parent_key_tuples(conn, table, std::slice::from_ref(&canonical_col))? { used.insert(seed_value_list_key(&tuple)); } } @@ -9457,8 +9373,10 @@ fn do_seed_column_fill( break v; }; let bound = impl_value_for(&schema, &canonical_col, &value)?; - let params: Vec = - vec![bound_to_sqlite_value(&bound), rusqlite::types::Value::Integer(*rowid)]; + let params: Vec = vec![ + bound_to_sqlite_value(&bound), + rusqlite::types::Value::Integer(*rowid), + ]; execute_with_fk_enrichment(conn, table, &update_sql, ¶ms)?; produced += 1; let _ = offset; @@ -9550,10 +9468,7 @@ fn insert_one_row( // thread serialisation (ADR-0010) makes this read-then- // write sequence safe without explicit locking. for c in &schema.columns { - if c.user_type == Some(Type::Serial) - && !c.primary_key - && !provided.contains(&c.name) - { + if c.user_type == Some(Type::Serial) && !c.primary_key && !provided.contains(&c.name) { let next: i64 = conn .query_row( &format!( @@ -9597,8 +9512,10 @@ fn insert_one_row( ident = quote_ident(table), ); debug!(sql = %sql, "insert"); - let params: Vec = - bindings.iter().map(|(_, b)| bound_to_sqlite_value(b)).collect(); + let params: Vec = bindings + .iter() + .map(|(_, b)| bound_to_sqlite_value(b)) + .collect(); let rows_affected = execute_with_fk_enrichment(conn, table, &sql, ¶ms)?; let new_rowid = conn.last_insert_rowid(); Ok((rows_affected, new_rowid)) @@ -9628,7 +9545,10 @@ fn do_insert( }; finalize_persistence(conn, persistence, &changes)?; tx.commit().map_err(DbError::from_rusqlite)?; - Ok(InsertResult { rows_affected, data }) + Ok(InsertResult { + rows_affected, + data, + }) } /// Build the parameterised `UPDATE … SET … WHERE …` statement. @@ -9763,7 +9683,10 @@ fn build_delete_sql( format!(" WHERE {}", compile_expr(expr, schema, &mut params)) } }; - let sql = format!("DELETE FROM {ident}{where_sql};", ident = quote_ident(table)); + let sql = format!( + "DELETE FROM {ident}{where_sql};", + ident = quote_ident(table) + ); (sql, params) } @@ -9860,11 +9783,7 @@ fn do_delete( /// Currently-stored non-NULL values of one column, for shortid /// collision-avoidance (passed to `generate_shortid_batch`). -fn existing_shortids( - conn: &Connection, - table: &str, - column: &str, -) -> Result, DbError> { +fn existing_shortids(conn: &Connection, table: &str, column: &str) -> Result, DbError> { let mut stmt = conn .prepare(&format!( "SELECT {col} FROM {tbl} WHERE {col} IS NOT NULL;", @@ -9932,8 +9851,10 @@ fn plan_autogen_autofill( // Identifiers are case-preserving but matched case-insensitively // (ADR-0009): a column counts as omitted unless the user listed a // name equal to it ignoring ASCII case. - let listed_ci: Vec = - listed_columns.iter().map(|c| c.to_ascii_lowercase()).collect(); + let listed_ci: Vec = listed_columns + .iter() + .map(|c| c.to_ascii_lowercase()) + .collect(); let is_omitted = |c: &&ReadColumn| !listed_ci.contains(&c.name.to_ascii_lowercase()); let omitted_shortids: Vec = schema .columns @@ -10020,8 +9941,11 @@ fn plan_autogen_autofill( |row| row.get(0), ) .map_err(DbError::from_rusqlite)?; - serial_batches - .push((1..=n as i64).map(|i| rusqlite::types::Value::Integer(max + i)).collect()); + serial_batches.push( + (1..=n as i64) + .map(|i| rusqlite::types::Value::Integer(max + i)) + .collect(), + ); } // Reconstruct: listed columns, then the omitted shortid columns, @@ -10156,7 +10080,13 @@ fn do_sql_insert( String::new() } else { sql.find(row_source) - .map(|i| sql[i + row_source.len()..].trim().trim_end_matches(';').trim().to_string()) + .map(|i| { + sql[i + row_source.len()..] + .trim() + .trim_end_matches(';') + .trim() + .to_string() + }) .unwrap_or_default() }; // Sub-phase 3d: when the user's column list omits one or more @@ -10165,8 +10095,14 @@ fn do_sql_insert( // rows. Returns the executable SQL + bound params; an empty // params vec with the original `sql` means "no auto-fill — // execute verbatim" (the 3b path). - let (exec_sql, params) = - plan_autogen_autofill(conn, target_table, sql, listed_columns, row_source, &trailing_tail)?; + let (exec_sql, params) = plan_autogen_autofill( + conn, + target_table, + sql, + listed_columns, + row_source, + &trailing_tail, + )?; let tx = conn .unchecked_transaction() .map_err(DbError::from_rusqlite)?; @@ -10277,7 +10213,10 @@ fn do_sql_update( }; finalize_persistence(conn, persistence, &changes)?; tx.commit().map_err(DbError::from_rusqlite)?; - Ok(UpdateResult { rows_affected, data }) + Ok(UpdateResult { + rows_affected, + data, + }) } /// Worker handler for `Request::RunSqlDelete` (ADR-0033 §1/§7, @@ -10440,8 +10379,7 @@ fn run_returning( table_name: &str, ) -> Result { let mut stmt = conn.prepare(sql).map_err(DbError::from_rusqlite)?; - let column_names: Vec = - stmt.column_names().into_iter().map(String::from).collect(); + let column_names: Vec = stmt.column_names().into_iter().map(String::from).collect(); let col_count = column_names.len(); let column_types = resolve_select_column_types(conn, &stmt); let rows_iter = stmt @@ -10475,11 +10413,7 @@ fn run_returning( fn do_run_select(conn: &Connection, sql: &str) -> Result { debug!(sql = %sql, "run_select"); let mut stmt = conn.prepare(sql).map_err(DbError::from_rusqlite)?; - let column_names: Vec = stmt - .column_names() - .into_iter() - .map(String::from) - .collect(); + let column_names: Vec = stmt.column_names().into_iter().map(String::from).collect(); let col_count = column_names.len(); let column_types = resolve_select_column_types(conn, &stmt); let rows_iter = stmt @@ -10521,10 +10455,7 @@ fn do_run_select(conn: &Connection, sql: &str) -> Result { /// `None` — Amendment 1 documents that recursive-CTE result /// columns and computed projections are the only structural /// classes that don't follow through. -fn resolve_select_column_types( - conn: &Connection, - stmt: &rusqlite::Statement, -) -> Vec> { +fn resolve_select_column_types(conn: &Connection, stmt: &rusqlite::Statement) -> Vec> { let metas = stmt.columns_with_metadata(); if metas.is_empty() { return Vec::new(); @@ -10614,8 +10545,7 @@ fn do_query_data( debug!(table = %table, limit = ?limit, "query_data"); let schema = read_schema(conn, table)?; let column_names: Vec = schema.columns.iter().map(|c| c.name.clone()).collect(); - let column_types: Vec> = - schema.columns.iter().map(|c| c.user_type).collect(); + let column_types: Vec> = schema.columns.iter().map(|c| c.user_type).collect(); let (sql, params) = build_query_data_sql(&schema, table, filter, limit); debug!(sql = %sql, "query_data"); @@ -10960,12 +10890,11 @@ fn do_rebuild_from_text(conn: &Connection, project_path: &Path) -> Result<(), Db let yaml_path = project_path.join(PROJECT_YAML); let data_dir = project_path.join(DATA_DIR); - let yaml_body = - std::fs::read_to_string(&yaml_path).map_err(|e| DbError::PersistenceFatal { - operation: "read", - path: yaml_path.clone(), - message: e.to_string(), - })?; + let yaml_body = std::fs::read_to_string(&yaml_path).map_err(|e| DbError::PersistenceFatal { + operation: "read", + path: yaml_path.clone(), + message: e.to_string(), + })?; let snapshot = parse_schema(&yaml_body).map_err(|e| DbError::PersistenceFatal { operation: "parse", path: yaml_path.clone(), @@ -10988,11 +10917,8 @@ fn do_rebuild_from_text(conn: &Connection, project_path: &Path) -> Result<(), Db // the text-source state per ADR-0015 §7. let existing_tables = do_list_tables(&tx)?; for name in &existing_tables { - tx.execute_batch(&format!( - "DROP TABLE {ident};", - ident = quote_ident(name) - )) - .map_err(DbError::from_rusqlite)?; + tx.execute_batch(&format!("DROP TABLE {ident};", ident = quote_ident(name))) + .map_err(DbError::from_rusqlite)?; } tx.execute_batch(&format!( "DELETE FROM {META_TABLE}; DELETE FROM {REL_TABLE};" @@ -11082,10 +11008,7 @@ fn do_rebuild_from_text(conn: &Connection, project_path: &Path) -> Result<(), Db for table in &snapshot.tables { for (seq, check) in table.check_constraints.iter().enumerate() { stmt.execute(rusqlite::params![ - table.name, - seq as i64, - check.expr, - check.name, + table.name, seq as i64, check.expr, check.name, ]) .map_err(DbError::from_rusqlite)?; } @@ -11402,7 +11325,8 @@ mod tests { name.to_string(), vec![col("id", Type::Serial)], vec!["id".to_string()], - None) + None, + ) .await .expect("create table") } @@ -11441,7 +11365,8 @@ mod tests { "Customers".to_string(), vec![col("email", Type::Text)], vec!["email".to_string()], - None) + None, + ) .await .unwrap(); assert_eq!(desc.columns.len(), 1); @@ -11459,7 +11384,8 @@ mod tests { "OrderLines".to_string(), vec![col("order_id", Type::Int), col("product_id", Type::Int)], vec!["order_id".to_string(), "product_id".to_string()], - None) + None, + ) .await .unwrap(); assert_eq!(desc.columns.len(), 2); @@ -11476,7 +11402,8 @@ mod tests { "T".to_string(), vec![col("flag", Type::Bool)], vec!["flag".to_string()], - None) + None, + ) .await .unwrap(); assert!(desc.columns[0].primary_key); @@ -11508,7 +11435,16 @@ mod tests { ) .await .unwrap_err(); - assert!(matches!(err, DbError::Sqlite { kind: SqliteErrorKind::NoSuchTable, .. }), "got {err:?}"); + assert!( + matches!( + err, + DbError::Sqlite { + kind: SqliteErrorKind::NoSuchTable, + .. + } + ), + "got {err:?}" + ); assert!(db.list_tables().await.unwrap().is_empty()); } @@ -11526,7 +11462,11 @@ mod tests { let db = db(); make_id_table(&db, "Customers").await; let result = db - .add_column("Customers".to_string(), ColumnSpec::new("Name".to_string(), Type::Text), None) + .add_column( + "Customers".to_string(), + ColumnSpec::new("Name".to_string(), Type::Text), + None, + ) .await .unwrap(); let desc = &result.description; @@ -11535,7 +11475,10 @@ mod tests { let name_col = desc.columns.iter().find(|c| c.name == "Name").unwrap(); assert_eq!(name_col.user_type, Some(Type::Text)); assert_eq!(name_col.sqlite_type.to_uppercase(), "TEXT"); - assert!(result.client_side_notes.is_empty(), "no auto-fill for plain types"); + assert!( + result.client_side_notes.is_empty(), + "no auto-fill for plain types" + ); } #[tokio::test] @@ -11546,9 +11489,13 @@ mod tests { // datetime, decimal — all backed by TEXT). make_id_table(&db, "T").await; for ty in [Type::Date, Type::DateTime, Type::Decimal, Type::ShortId] { - db.add_column("T".to_string(), ColumnSpec::new(format!("c_{ty}"), ty), None) - .await - .unwrap(); + db.add_column( + "T".to_string(), + ColumnSpec::new(format!("c_{ty}"), ty), + None, + ) + .await + .unwrap(); } let desc = db.describe_table("T".to_string()).await.unwrap(); let id_col = desc.columns.iter().find(|c| c.name == "id").unwrap(); @@ -11578,7 +11525,8 @@ mod tests { "T".to_string(), vec![col("when", Type::Date)], vec!["when".to_string()], - None) + None, + ) .await .unwrap(); let before = db.describe_table("T".to_string()).await.unwrap(); @@ -11595,7 +11543,8 @@ mod tests { "T".to_string(), vec![col("when", Type::DateTime)], vec!["when".to_string()], - None) + None, + ) .await .unwrap(); let after = db.describe_table("T".to_string()).await.unwrap(); @@ -11625,7 +11574,11 @@ mod tests { let db = db(); make_id_table(&db, "T").await; let result = db - .add_column("T".to_string(), ColumnSpec::new("code".to_string(), Type::Serial), None) + .add_column( + "T".to_string(), + ColumnSpec::new("code".to_string(), Type::Serial), + None, + ) .await .unwrap(); let code = result @@ -11649,9 +11602,13 @@ mod tests { /// (text) and insert N rows, populating just `Name`. async fn make_table_with_n_rows(db: &Database, table: &str, count: usize) { make_id_table(db, table).await; - db.add_column(table.to_string(), ColumnSpec::new("Name".to_string(), Type::Text), None) - .await - .unwrap(); + db.add_column( + table.to_string(), + ColumnSpec::new("Name".to_string(), Type::Text), + None, + ) + .await + .unwrap(); for i in 0..count { db.insert( table.to_string(), @@ -11669,7 +11626,11 @@ mod tests { let db = db(); make_table_with_n_rows(&db, "T", 3).await; let result = db - .add_column("T".to_string(), ColumnSpec::new("seq".to_string(), Type::Serial), None) + .add_column( + "T".to_string(), + ColumnSpec::new("seq".to_string(), Type::Serial), + None, + ) .await .unwrap(); let seq = result @@ -11679,7 +11640,10 @@ mod tests { .find(|c| c.name == "seq") .unwrap(); assert_eq!(seq.user_type, Some(Type::Serial)); - assert!(!result.client_side_notes.is_empty(), "auto-fill note expected"); + assert!( + !result.client_side_notes.is_empty(), + "auto-fill note expected" + ); assert!( result.client_side_notes[0].contains("3 row(s) given auto-generated serial"), "unexpected note: {:?}", @@ -11702,7 +11666,11 @@ mod tests { let db = db(); make_table_with_n_rows(&db, "T", 3).await; let result = db - .add_column("T".to_string(), ColumnSpec::new("tag".to_string(), Type::ShortId), None) + .add_column( + "T".to_string(), + ColumnSpec::new("tag".to_string(), Type::ShortId), + None, + ) .await .unwrap(); let tag = result @@ -11712,7 +11680,10 @@ mod tests { .find(|c| c.name == "tag") .unwrap(); assert_eq!(tag.user_type, Some(Type::ShortId)); - assert!(!result.client_side_notes.is_empty(), "auto-fill note expected"); + assert!( + !result.client_side_notes.is_empty(), + "auto-fill note expected" + ); assert!( result.client_side_notes[0].contains("3 row(s) given auto-generated shortid"), "unexpected note: {:?}", @@ -11737,9 +11708,13 @@ mod tests { // columns continue to use SQLite's rowid alias. let db = db(); make_table_with_n_rows(&db, "T", 0).await; - db.add_column("T".to_string(), ColumnSpec::new("seq".to_string(), Type::Serial), None) - .await - .unwrap(); + db.add_column( + "T".to_string(), + ColumnSpec::new("seq".to_string(), Type::Serial), + None, + ) + .await + .unwrap(); // Insert three rows providing only `Name`. The seq // column should auto-fill 1, 2, 3. for n in ["a", "b", "c"] { @@ -11770,14 +11745,21 @@ mod tests { // accepted (ADR-0018 Resolution 2). let db = db(); make_table_with_n_rows(&db, "T", 0).await; - db.add_column("T".to_string(), ColumnSpec::new("seq".to_string(), Type::Serial), None) - .await - .unwrap(); + db.add_column( + "T".to_string(), + ColumnSpec::new("seq".to_string(), Type::Serial), + None, + ) + .await + .unwrap(); // Insert with explicit seq=100. db.insert( "T".to_string(), Some(vec!["Name".to_string(), "seq".to_string()]), - vec![Value::Text("a".to_string()), Value::Number("100".to_string())], + vec![ + Value::Text("a".to_string()), + Value::Number("100".to_string()), + ], None, ) .await @@ -11809,9 +11791,13 @@ mod tests { // confirming the engine refuses. let db = db(); make_table_with_n_rows(&db, "T", 2).await; - db.add_column("T".to_string(), ColumnSpec::new("seq".to_string(), Type::Serial), None) - .await - .unwrap(); + db.add_column( + "T".to_string(), + ColumnSpec::new("seq".to_string(), Type::Serial), + None, + ) + .await + .unwrap(); // Attempt to UPDATE one row to have the same `seq` value // as the other — should violate UNIQUE. let err = db @@ -11842,7 +11828,8 @@ mod tests { "T".to_string(), vec![col("id", Type::Serial)], vec!["id".to_string()], - None) + None, + ) .await .unwrap_err(); match err { @@ -11865,7 +11852,11 @@ mod tests { async fn add_column_to_missing_table_returns_no_such_table() { let db = db(); let err = db - .add_column("Ghost".to_string(), ColumnSpec::new("x".to_string(), Type::Text), None) + .add_column( + "Ghost".to_string(), + ColumnSpec::new("x".to_string(), Type::Text), + None, + ) .await .unwrap_err(); match err { @@ -11880,9 +11871,13 @@ mod tests { async fn drop_column_removes_column_and_data() { let db = db(); make_id_table(&db, "T").await; - db.add_column("T".to_string(), ColumnSpec::new("Score".to_string(), Type::Int), None) - .await - .unwrap(); + db.add_column( + "T".to_string(), + ColumnSpec::new("Score".to_string(), Type::Int), + None, + ) + .await + .unwrap(); db.insert( "T".to_string(), None, @@ -11930,7 +11925,11 @@ mod tests { // Customers(id PK) ← Orders(cust_id FK) make_id_table(&db, "Customers").await; make_id_table(&db, "Orders").await; - db.add_column("Orders".to_string(), ColumnSpec::new("cust_id".to_string(), Type::Int), None) + db.add_column( + "Orders".to_string(), + ColumnSpec::new("cust_id".to_string(), Type::Int), + None, + ) .await .unwrap(); db.add_relationship( @@ -11975,9 +11974,13 @@ mod tests { /// something indexable. async fn make_indexable_table(db: &Database, name: &str) { make_id_table(db, name).await; - db.add_column(name.to_string(), ColumnSpec::new("Email".to_string(), Type::Text), None) - .await - .expect("add Email column"); + db.add_column( + name.to_string(), + ColumnSpec::new("Email".to_string(), Type::Text), + None, + ) + .await + .expect("add Email column"); } #[tokio::test] @@ -12003,7 +12006,12 @@ mod tests { let db = db(); make_indexable_table(&db, "Customers").await; let desc = db - .add_index(None, "Customers".to_string(), vec!["Email".to_string()], None) + .add_index( + None, + "Customers".to_string(), + vec!["Email".to_string()], + None, + ) .await .expect("add index"); assert_eq!(desc.indexes[0].name, "Customers_Email_idx"); @@ -12013,12 +12021,20 @@ mod tests { async fn add_index_composite_auto_name_joins_columns() { let db = db(); make_id_table(&db, "Orders").await; - db.add_column("Orders".to_string(), ColumnSpec::new("CustId".to_string(), Type::Int), None) - .await - .unwrap(); - db.add_column("Orders".to_string(), ColumnSpec::new("Day".to_string(), Type::Date), None) - .await - .unwrap(); + db.add_column( + "Orders".to_string(), + ColumnSpec::new("CustId".to_string(), Type::Int), + None, + ) + .await + .unwrap(); + db.add_column( + "Orders".to_string(), + ColumnSpec::new("Day".to_string(), Type::Date), + None, + ) + .await + .unwrap(); let desc = db .add_index( None, @@ -12039,9 +12055,13 @@ mod tests { async fn add_index_rejects_duplicate_name() { let db = db(); make_indexable_table(&db, "Customers").await; - db.add_column("Customers".to_string(), ColumnSpec::new("Nick".to_string(), Type::Text), None) - .await - .unwrap(); + db.add_column( + "Customers".to_string(), + ColumnSpec::new("Nick".to_string(), Type::Text), + None, + ) + .await + .unwrap(); db.add_index( Some("idx".to_string()), "Customers".to_string(), @@ -12091,7 +12111,12 @@ mod tests { let db = db(); make_indexable_table(&db, "Customers").await; let err = db - .add_index(None, "Customers".to_string(), vec!["Ghost".to_string()], None) + .add_index( + None, + "Customers".to_string(), + vec!["Ghost".to_string()], + None, + ) .await .unwrap_err(); assert!( @@ -12153,9 +12178,14 @@ mod tests { async fn drop_index_by_columns_removes_it() { let db = db(); make_indexable_table(&db, "Customers").await; - db.add_index(None, "Customers".to_string(), vec!["Email".to_string()], None) - .await - .unwrap(); + db.add_index( + None, + "Customers".to_string(), + vec!["Email".to_string()], + None, + ) + .await + .unwrap(); let desc = db .drop_index( IndexSelector::Columns { @@ -12223,13 +12253,7 @@ mod tests { .expect("drop column --cascade"); assert_eq!(result.dropped_indexes, vec!["idx_email".to_string()]); assert!(result.description.indexes.is_empty()); - assert!( - result - .description - .columns - .iter() - .all(|c| c.name != "Email"), - ); + assert!(result.description.columns.iter().all(|c| c.name != "Email"),); } #[tokio::test] @@ -12238,9 +12262,13 @@ mod tests { // unrelated column must survive the rebuild (ADR-0025). let db = db(); make_indexable_table(&db, "T").await; - db.add_column("T".to_string(), ColumnSpec::new("Score".to_string(), Type::Int), None) - .await - .unwrap(); + db.add_column( + "T".to_string(), + ColumnSpec::new("Score".to_string(), Type::Int), + None, + ) + .await + .unwrap(); db.add_index( Some("idx_email".to_string()), "T".to_string(), @@ -12271,9 +12299,13 @@ mod tests { async fn rename_column_updates_schema_and_metadata() { let db = db(); make_id_table(&db, "T").await; - db.add_column("T".to_string(), ColumnSpec::new("Old".to_string(), Type::Text), None) - .await - .unwrap(); + db.add_column( + "T".to_string(), + ColumnSpec::new("Old".to_string(), Type::Text), + None, + ) + .await + .unwrap(); let desc = db .rename_column("T".to_string(), "Old".to_string(), "New".to_string(), None) .await @@ -12290,7 +12322,11 @@ mod tests { let db = db(); make_id_table(&db, "Customers").await; make_id_table(&db, "Orders").await; - db.add_column("Orders".to_string(), ColumnSpec::new("cust_id".to_string(), Type::Int), None) + db.add_column( + "Orders".to_string(), + ColumnSpec::new("cust_id".to_string(), Type::Int), + None, + ) .await .unwrap(); db.add_relationship( @@ -12316,10 +12352,7 @@ mod tests { ) .await .unwrap(); - let orders = db - .describe_table("Orders".to_string()) - .await - .unwrap(); + let orders = db.describe_table("Orders".to_string()).await.unwrap(); let outbound = orders .outbound_relationships .iter() @@ -12331,10 +12364,7 @@ mod tests { ); // Same from the parent perspective via inbound. - let customers = db - .describe_table("Customers".to_string()) - .await - .unwrap(); + let customers = db.describe_table("Customers".to_string()).await.unwrap(); let inbound = customers .inbound_relationships .iter() @@ -12350,12 +12380,20 @@ mod tests { async fn rename_column_refuses_collision() { let db = db(); make_id_table(&db, "T").await; - db.add_column("T".to_string(), ColumnSpec::new("A".to_string(), Type::Text), None) - .await - .unwrap(); - db.add_column("T".to_string(), ColumnSpec::new("B".to_string(), Type::Text), None) - .await - .unwrap(); + db.add_column( + "T".to_string(), + ColumnSpec::new("A".to_string(), Type::Text), + None, + ) + .await + .unwrap(); + db.add_column( + "T".to_string(), + ColumnSpec::new("B".to_string(), Type::Text), + None, + ) + .await + .unwrap(); let err = db .rename_column("T".to_string(), "A".to_string(), "B".to_string(), None) .await @@ -12367,9 +12405,13 @@ mod tests { async fn rename_column_refuses_identity_rename() { let db = db(); make_id_table(&db, "T").await; - db.add_column("T".to_string(), ColumnSpec::new("A".to_string(), Type::Text), None) - .await - .unwrap(); + db.add_column( + "T".to_string(), + ColumnSpec::new("A".to_string(), Type::Text), + None, + ) + .await + .unwrap(); let err = db .rename_column("T".to_string(), "A".to_string(), "A".to_string(), None) .await @@ -12381,9 +12423,13 @@ mod tests { async fn change_column_type_works_for_compatible_data() { let db = db(); make_id_table(&db, "T").await; - db.add_column("T".to_string(), ColumnSpec::new("Score".to_string(), Type::Text), None) - .await - .unwrap(); + db.add_column( + "T".to_string(), + ColumnSpec::new("Score".to_string(), Type::Text), + None, + ) + .await + .unwrap(); // Insert numeric-looking strings. for v in ["1", "2", "3"] { db.insert( @@ -12450,7 +12496,11 @@ mod tests { let db = db(); make_id_table(&db, "Customers").await; make_id_table(&db, "Orders").await; - db.add_column("Orders".to_string(), ColumnSpec::new("cust_id".to_string(), Type::Int), None) + db.add_column( + "Orders".to_string(), + ColumnSpec::new("cust_id".to_string(), Type::Int), + None, + ) .await .unwrap(); db.add_relationship( @@ -12494,7 +12544,11 @@ mod tests { let db = db(); make_id_table(&db, "Customers").await; make_id_table(&db, "Orders").await; - db.add_column("Orders".to_string(), ColumnSpec::new("cust_id".to_string(), Type::Int), None) + db.add_column( + "Orders".to_string(), + ColumnSpec::new("cust_id".to_string(), Type::Int), + None, + ) .await .unwrap(); db.add_relationship( @@ -12530,9 +12584,13 @@ mod tests { // table. let db = db(); make_id_table(&db, "T").await; - db.add_column("T".to_string(), ColumnSpec::new("Score".to_string(), Type::Real), None) - .await - .unwrap(); + db.add_column( + "T".to_string(), + ColumnSpec::new("Score".to_string(), Type::Real), + None, + ) + .await + .unwrap(); for v in ["3.14", "2.71"] { db.insert( "T".to_string(), @@ -12581,9 +12639,13 @@ mod tests { // the lossy count. let db = db(); make_id_table(&db, "T").await; - db.add_column("T".to_string(), ColumnSpec::new("Score".to_string(), Type::Real), None) - .await - .unwrap(); + db.add_column( + "T".to_string(), + ColumnSpec::new("Score".to_string(), Type::Real), + None, + ) + .await + .unwrap(); for v in ["3.14", "2.71", "5.0"] { db.insert( "T".to_string(), @@ -12616,9 +12678,13 @@ mod tests { // does NOT help (per ADR-0017 §5 / §2 step 3). let db = db(); make_id_table(&db, "T").await; - db.add_column("T".to_string(), ColumnSpec::new("Note".to_string(), Type::Text), None) - .await - .unwrap(); + db.add_column( + "T".to_string(), + ColumnSpec::new("Note".to_string(), Type::Text), + None, + ) + .await + .unwrap(); for v in ["abc", "123", "xyz"] { db.insert( "T".to_string(), @@ -12631,13 +12697,7 @@ mod tests { } for mode in [ChangeColumnMode::Default, ChangeColumnMode::ForceConversion] { let err = db - .change_column_type( - "T".to_string(), - "Note".to_string(), - Type::Int, - mode, - None, - ) + .change_column_type("T".to_string(), "Note".to_string(), Type::Int, mode, None) .await .unwrap_err(); match err { @@ -12665,9 +12725,13 @@ mod tests { // [client-side] note is expected. let db = db(); make_id_table(&db, "T").await; - db.add_column("T".to_string(), ColumnSpec::new("Flag".to_string(), Type::Int), None) - .await - .unwrap(); + db.add_column( + "T".to_string(), + ColumnSpec::new("Flag".to_string(), Type::Int), + None, + ) + .await + .unwrap(); for v in ["0", "1", "0"] { db.insert( "T".to_string(), @@ -12713,9 +12777,13 @@ mod tests { // help (incompatible is not lossy). let db = db(); make_id_table(&db, "T").await; - db.add_column("T".to_string(), ColumnSpec::new("Flag".to_string(), Type::Int), None) - .await - .unwrap(); + db.add_column( + "T".to_string(), + ColumnSpec::new("Flag".to_string(), Type::Int), + None, + ) + .await + .unwrap(); for v in ["0", "1", "2"] { db.insert( "T".to_string(), @@ -12728,13 +12796,7 @@ mod tests { } for mode in [ChangeColumnMode::Default, ChangeColumnMode::ForceConversion] { let err = db - .change_column_type( - "T".to_string(), - "Flag".to_string(), - Type::Bool, - mode, - None, - ) + .change_column_type("T".to_string(), "Flag".to_string(), Type::Bool, mode, None) .await .unwrap_err(); match err { @@ -12765,9 +12827,13 @@ mod tests { // engine coercion; the note is suppressed. let db = db(); make_id_table(&db, "T").await; - db.add_column("T".to_string(), ColumnSpec::new("Score".to_string(), Type::Text), None) - .await - .unwrap(); + db.add_column( + "T".to_string(), + ColumnSpec::new("Score".to_string(), Type::Text), + None, + ) + .await + .unwrap(); for v in ["1", "2", "3"] { db.insert( "T".to_string(), @@ -12850,9 +12916,13 @@ mod tests { async fn change_column_type_blob_target_refused_statically() { let db = db(); make_id_table(&db, "T").await; - db.add_column("T".to_string(), ColumnSpec::new("Note".to_string(), Type::Text), None) - .await - .unwrap(); + db.add_column( + "T".to_string(), + ColumnSpec::new("Note".to_string(), Type::Text), + None, + ) + .await + .unwrap(); let err = db .change_column_type( "T".to_string(), @@ -12873,7 +12943,11 @@ mod tests { let db = db(); make_id_table(&db, "Customers").await; make_id_table(&db, "Orders").await; - db.add_column("Orders".to_string(), ColumnSpec::new("cust_id".to_string(), Type::Int), None) + db.add_column( + "Orders".to_string(), + ColumnSpec::new("cust_id".to_string(), Type::Int), + None, + ) .await .unwrap(); db.add_relationship( @@ -12916,9 +12990,13 @@ mod tests { // note, and the structural change goes through. let db = db(); make_id_table(&db, "T").await; - db.add_column("T".to_string(), ColumnSpec::new("Note".to_string(), Type::Text), None) - .await - .unwrap(); + db.add_column( + "T".to_string(), + ColumnSpec::new("Note".to_string(), Type::Text), + None, + ) + .await + .unwrap(); let result = db .change_column_type( "T".to_string(), @@ -12949,9 +13027,13 @@ mod tests { // existing values are preserved. let db = db(); make_table_with_n_rows(&db, "T", 0).await; - db.add_column("T".to_string(), ColumnSpec::new("code".to_string(), Type::Int), None) - .await - .unwrap(); + db.add_column( + "T".to_string(), + ColumnSpec::new("code".to_string(), Type::Int), + None, + ) + .await + .unwrap(); // Insert a few rows with explicit code values. for (i, code) in [(1, 10), (2, 20), (3, 30)] { db.insert( @@ -12999,15 +13081,22 @@ mod tests { // uniqueness-collision diagnostic. let db = db(); make_table_with_n_rows(&db, "T", 0).await; - db.add_column("T".to_string(), ColumnSpec::new("code".to_string(), Type::Int), None) - .await - .unwrap(); + db.add_column( + "T".to_string(), + ColumnSpec::new("code".to_string(), Type::Int), + None, + ) + .await + .unwrap(); // Two rows with the same code. for i in 0..2 { db.insert( "T".to_string(), Some(vec!["Name".to_string(), "code".to_string()]), - vec![Value::Text(format!("row{i}")), Value::Number("7".to_string())], + vec![ + Value::Text(format!("row{i}")), + Value::Number("7".to_string()), + ], None, ) .await @@ -13041,9 +13130,13 @@ mod tests { // to route through int. let db = db(); make_id_table(&db, "T").await; - db.add_column("T".to_string(), ColumnSpec::new("A".to_string(), Type::Text), None) - .await - .unwrap(); + db.add_column( + "T".to_string(), + ColumnSpec::new("A".to_string(), Type::Text), + None, + ) + .await + .unwrap(); let err = db .change_column_type( "T".to_string(), @@ -13072,9 +13165,13 @@ mod tests { // [client-side] note reports the auto-fill count. let db = db(); make_table_with_n_rows(&db, "T", 0).await; - db.add_column("T".to_string(), ColumnSpec::new("code".to_string(), Type::Int), None) - .await - .unwrap(); + db.add_column( + "T".to_string(), + ColumnSpec::new("code".to_string(), Type::Int), + None, + ) + .await + .unwrap(); // Three rows: one with code=5, two with NULL. db.insert( "T".to_string(), @@ -13128,9 +13225,13 @@ mod tests { // text column get fresh shortids (ADR-0018 §3). let db = db(); make_table_with_n_rows(&db, "T", 0).await; - db.add_column("T".to_string(), ColumnSpec::new("tag".to_string(), Type::Text), None) - .await - .unwrap(); + db.add_column( + "T".to_string(), + ColumnSpec::new("tag".to_string(), Type::Text), + None, + ) + .await + .unwrap(); // One row with a valid shortid value, two with NULL. db.insert( "T".to_string(), @@ -13182,7 +13283,11 @@ mod tests { let db = db(); make_id_table(&db, "Customers").await; make_id_table(&db, "Orders").await; - db.add_column("Orders".to_string(), ColumnSpec::new("cust_id".to_string(), Type::Int), None) + db.add_column( + "Orders".to_string(), + ColumnSpec::new("cust_id".to_string(), Type::Int), + None, + ) .await .unwrap(); db.add_relationship( @@ -13223,11 +13328,21 @@ mod tests { async fn change_column_type_no_op_to_same_type_errors() { let db = db(); make_id_table(&db, "T").await; - db.add_column("T".to_string(), ColumnSpec::new("A".to_string(), Type::Int), None) - .await - .unwrap(); + db.add_column( + "T".to_string(), + ColumnSpec::new("A".to_string(), Type::Int), + None, + ) + .await + .unwrap(); let err = db - .change_column_type("T".to_string(), "A".to_string(), Type::Int, ChangeColumnMode::Default, None) + .change_column_type( + "T".to_string(), + "A".to_string(), + Type::Int, + ChangeColumnMode::Default, + None, + ) .await .unwrap_err(); assert!(matches!(err, DbError::Unsupported(_)), "got {err:?}"); @@ -13250,19 +13365,25 @@ mod tests { "Customers".to_string(), vec![col("id", Type::Serial), col("Name", Type::Text)], vec!["id".to_string()], - None) + None, + ) .await .unwrap(); db.create_table( "Orders".to_string(), vec![col("id", Type::Serial)], vec!["id".to_string()], - None) + None, + ) + .await + .unwrap(); + db.add_column( + "Orders".to_string(), + ColumnSpec::new("CustId".to_string(), Type::Int), + None, + ) .await .unwrap(); - db.add_column("Orders".to_string(), ColumnSpec::new("CustId".to_string(), Type::Int), None) - .await - .unwrap(); } #[tokio::test] @@ -13278,7 +13399,8 @@ mod tests { ReferentialAction::NoAction, ReferentialAction::NoAction, false, - None) + None, + ) .await .unwrap(); let orders = db.describe_table("Orders".to_string()).await.unwrap(); @@ -13303,7 +13425,8 @@ mod tests { ReferentialAction::NoAction, ReferentialAction::NoAction, false, - None) + None, + ) .await .unwrap(); let customers = db.describe_table("Customers".to_string()).await.unwrap(); @@ -13327,7 +13450,8 @@ mod tests { ReferentialAction::Cascade, ReferentialAction::SetNull, false, - None) + None, + ) .await .unwrap(); let orders = db.describe_table("Orders".to_string()).await.unwrap(); @@ -13345,14 +13469,16 @@ mod tests { "Customers".to_string(), vec![col("id", Type::Serial)], vec!["id".to_string()], - None) + None, + ) .await .unwrap(); db.create_table( "Orders".to_string(), vec![col("id", Type::Serial)], vec!["id".to_string()], - None) + None, + ) .await .unwrap(); @@ -13388,14 +13514,16 @@ mod tests { "Customers".to_string(), vec![col("id", Type::Serial)], vec!["id".to_string()], - None) + None, + ) .await .unwrap(); db.create_table( "Orders".to_string(), vec![col("id", Type::Serial)], vec!["id".to_string()], - None) + None, + ) .await .unwrap(); let err = db @@ -13408,7 +13536,8 @@ mod tests { ReferentialAction::NoAction, ReferentialAction::NoAction, false, - None) + None, + ) .await .unwrap_err(); assert!(matches!(err, DbError::Unsupported(_)), "got {err:?}"); @@ -13421,20 +13550,26 @@ mod tests { "Customers".to_string(), vec![col("id", Type::Serial)], vec!["id".to_string()], - None) + None, + ) .await .unwrap(); db.create_table( "Orders".to_string(), vec![col("id", Type::Serial)], vec!["id".to_string()], - None) + None, + ) .await .unwrap(); // Wrong type — text instead of int. - db.add_column("Orders".to_string(), ColumnSpec::new("CustId".to_string(), Type::Text), None) - .await - .unwrap(); + db.add_column( + "Orders".to_string(), + ColumnSpec::new("CustId".to_string(), Type::Text), + None, + ) + .await + .unwrap(); let err = db .add_relationship( @@ -13446,7 +13581,8 @@ mod tests { ReferentialAction::NoAction, ReferentialAction::NoAction, false, - None) + None, + ) .await .unwrap_err(); match err { @@ -13466,19 +13602,25 @@ mod tests { "Customers".to_string(), vec![col("id", Type::Serial), col("Name", Type::Text)], vec!["id".to_string()], - None) + None, + ) .await .unwrap(); db.create_table( "Orders".to_string(), vec![col("id", Type::Serial)], vec!["id".to_string()], - None) + None, + ) + .await + .unwrap(); + db.add_column( + "Orders".to_string(), + ColumnSpec::new("CustName".to_string(), Type::Text), + None, + ) .await .unwrap(); - db.add_column("Orders".to_string(), ColumnSpec::new("CustName".to_string(), Type::Text), None) - .await - .unwrap(); let err = db .add_relationship( None, @@ -13489,7 +13631,8 @@ mod tests { ReferentialAction::NoAction, ReferentialAction::NoAction, false, - None) + None, + ) .await .unwrap_err(); match err { @@ -13511,12 +13654,16 @@ mod tests { ReferentialAction::NoAction, ReferentialAction::NoAction, false, - None) + None, + ) .await .unwrap(); - db.drop_relationship(RelationshipSelector::Named { - name: "cust_orders".to_string(), - }, None) + db.drop_relationship( + RelationshipSelector::Named { + name: "cust_orders".to_string(), + }, + None, + ) .await .unwrap(); let orders = db.describe_table("Orders".to_string()).await.unwrap(); @@ -13538,15 +13685,19 @@ mod tests { ReferentialAction::NoAction, ReferentialAction::NoAction, false, - None) + None, + ) .await .unwrap(); - db.drop_relationship(RelationshipSelector::Endpoints { - parent_table: "Customers".to_string(), - parent_column: "id".to_string(), - child_table: "Orders".to_string(), - child_column: "CustId".to_string(), - }, None) + db.drop_relationship( + RelationshipSelector::Endpoints { + parent_table: "Customers".to_string(), + parent_column: "id".to_string(), + child_table: "Orders".to_string(), + child_column: "CustId".to_string(), + }, + None, + ) .await .unwrap(); let orders = db.describe_table("Orders".to_string()).await.unwrap(); @@ -13566,10 +13717,14 @@ mod tests { ReferentialAction::NoAction, ReferentialAction::NoAction, false, - None) + None, + ) .await .unwrap(); - let err = db.drop_table("Customers".to_string(), None).await.unwrap_err(); + let err = db + .drop_table("Customers".to_string(), None) + .await + .unwrap_err(); match err { DbError::Unsupported(msg) => { assert!(msg.contains("referenced by"), "{msg}"); @@ -13591,7 +13746,8 @@ mod tests { ReferentialAction::NoAction, ReferentialAction::NoAction, false, - None) + None, + ) .await .unwrap(); // Dropping the child is allowed (no inbound relationships @@ -13605,9 +13761,13 @@ mod tests { async fn add_relationship_with_duplicate_name_errors() { let db = db(); customers_orders_setup(&db).await; - db.add_column("Orders".to_string(), ColumnSpec::new("OtherCust".to_string(), Type::Int), None) - .await - .unwrap(); + db.add_column( + "Orders".to_string(), + ColumnSpec::new("OtherCust".to_string(), Type::Int), + None, + ) + .await + .unwrap(); db.add_relationship( Some("dup".to_string()), "Customers".to_string(), @@ -13617,7 +13777,8 @@ mod tests { ReferentialAction::NoAction, ReferentialAction::NoAction, false, - None) + None, + ) .await .unwrap(); let err = db @@ -13630,7 +13791,8 @@ mod tests { ReferentialAction::NoAction, ReferentialAction::NoAction, false, - None) + None, + ) .await .unwrap_err(); match err { @@ -13657,7 +13819,8 @@ mod tests { ReferentialAction::Cascade, ReferentialAction::NoAction, false, - None) + None, + ) .await .unwrap(); // After the rebuild, the original columns are still @@ -13677,7 +13840,8 @@ mod tests { "Customers".to_string(), vec![col("id", Type::Serial), col("Name", Type::Text)], vec!["id".to_string()], - None) + None, + ) .await .unwrap(); } @@ -13691,14 +13855,18 @@ mod tests { "Customers".to_string(), None, vec![Value::Text("Alice".to_string())], - None) + None, + ) .await .unwrap(); assert_eq!(result.rows_affected, 1); // The InsertResult itself carries the just-inserted row. assert_eq!(result.data.rows.len(), 1); assert_eq!(result.data.rows[0][1], Some("Alice".to_string())); - let data = db.query_data("Customers".to_string(), None, None).await.unwrap(); + let data = db + .query_data("Customers".to_string(), None, None) + .await + .unwrap(); assert_eq!(data.columns, vec!["id".to_string(), "Name".to_string()]); assert_eq!(data.rows.len(), 1); assert_eq!(data.rows[0][1], Some("Alice".to_string())); @@ -13713,14 +13881,16 @@ mod tests { "Tags".to_string(), vec![col("id", Type::ShortId), col("Label", Type::Text)], vec!["id".to_string()], - None) + None, + ) .await .unwrap(); db.insert( "Tags".to_string(), None, vec![Value::Text("database".to_string())], - None) + None, + ) .await .unwrap(); let data = db.query_data("Tags".to_string(), None, None).await.unwrap(); @@ -13738,11 +13908,18 @@ mod tests { db.insert( "Customers".to_string(), Some(vec!["id".to_string(), "Name".to_string()]), - vec![Value::Number("99".to_string()), Value::Text("Bob".to_string())], - None) + vec![ + Value::Number("99".to_string()), + Value::Text("Bob".to_string()), + ], + None, + ) .await .unwrap(); - let data = db.query_data("Customers".to_string(), None, None).await.unwrap(); + let data = db + .query_data("Customers".to_string(), None, None) + .await + .unwrap(); assert_eq!(data.rows[0][0], Some("99".to_string())); assert_eq!(data.rows[0][1], Some("Bob".to_string())); } @@ -13757,7 +13934,8 @@ mod tests { "Customers".to_string(), Some(vec!["Name".to_string()]), vec![Value::Number("42".to_string())], - None) + None, + ) .await .unwrap_err(); assert!(matches!(err, DbError::InvalidValue(_)), "got {err:?}"); @@ -13772,7 +13950,8 @@ mod tests { "Customers".to_string(), None, vec![Value::Text(name.to_string())], - None) + None, + ) .await .unwrap(); } @@ -13781,14 +13960,18 @@ mod tests { "Customers".to_string(), vec![("Name".to_string(), Value::Text("Alicia".to_string()))], RowFilter::eq("id", Value::Number("1".to_string())), - None) + None, + ) .await .unwrap(); assert_eq!(result.rows_affected, 1); // The UpdateResult contains only the updated rows. assert_eq!(result.data.rows.len(), 1); assert_eq!(result.data.rows[0][1], Some("Alicia".to_string())); - let data = db.query_data("Customers".to_string(), None, None).await.unwrap(); + let data = db + .query_data("Customers".to_string(), None, None) + .await + .unwrap(); assert_eq!(data.rows[0][1], Some("Alicia".to_string())); assert_eq!(data.rows[1][1], Some("Bob".to_string())); } @@ -13852,9 +14035,7 @@ mod tests { /// `show data` command. fn parse_show(dsl: &str) -> (Option, Option) { match crate::dsl::parser::parse_command(dsl).expect("show parse") { - crate::dsl::command::Command::ShowData { filter, limit, .. } => { - (filter, limit) - } + crate::dsl::command::Command::ShowData { filter, limit, .. } => (filter, limit), other => panic!("expected show data, got {other:?}"), } } @@ -13874,9 +14055,7 @@ mod tests { let result = db .delete( "People".to_string(), - parse_filter( - "delete from People where Age >= 35 and Active = true", - ), + parse_filter("delete from People where Age >= 35 and Active = true"), None, ) .await @@ -13884,7 +14063,10 @@ mod tests { // Carol (45/true) and Dave (35/true) match; Bob (35) is // inactive, Alice (25) is too young. assert_eq!(result.rows_affected, 2); - let data = db.query_data("People".to_string(), None, None).await.unwrap(); + let data = db + .query_data("People".to_string(), None, None) + .await + .unwrap(); assert_eq!(names(&data), vec!["Alice", "Bob"]); } @@ -13901,7 +14083,10 @@ mod tests { .await .unwrap(); assert_eq!(result.rows_affected, 2); - let data = db.query_data("People".to_string(), None, None).await.unwrap(); + let data = db + .query_data("People".to_string(), None, None) + .await + .unwrap(); assert_eq!(names(&data), vec!["Bob", "Dave"]); } @@ -13919,7 +14104,10 @@ mod tests { .await .unwrap(); assert_eq!(result.rows_affected, 2); - let data = db.query_data("People".to_string(), None, None).await.unwrap(); + let data = db + .query_data("People".to_string(), None, None) + .await + .unwrap(); assert_eq!(names(&data), vec!["Bob", "Dave"]); } @@ -13937,7 +14125,10 @@ mod tests { .unwrap(); // Bob (35) and Dave (35) are in range. assert_eq!(result.rows_affected, 2); - let data = db.query_data("People".to_string(), None, None).await.unwrap(); + let data = db + .query_data("People".to_string(), None, None) + .await + .unwrap(); assert_eq!(names(&data), vec!["Alice", "Carol"]); } @@ -13948,15 +14139,16 @@ mod tests { let result = db .delete( "People".to_string(), - parse_filter( - "delete from People where Name in ('Alice', 'Carol')", - ), + parse_filter("delete from People where Name in ('Alice', 'Carol')"), None, ) .await .unwrap(); assert_eq!(result.rows_affected, 2); - let data = db.query_data("People".to_string(), None, None).await.unwrap(); + let data = db + .query_data("People".to_string(), None, None) + .await + .unwrap(); assert_eq!(names(&data), vec!["Bob", "Dave"]); } @@ -13973,7 +14165,10 @@ mod tests { .await .unwrap(); assert_eq!(result.rows_affected, 1, "only Alice matches `A%`"); - let data = db.query_data("People".to_string(), None, None).await.unwrap(); + let data = db + .query_data("People".to_string(), None, None) + .await + .unwrap(); assert_eq!(names(&data), vec!["Bob", "Carol", "Dave"]); } @@ -14028,8 +14223,7 @@ mod tests { async fn query_data_with_where_and_limit_combines_both() { let db = db(); people_table(&db).await; - let (filter, limit) = - parse_show("show data People where Age >= 35 limit 1"); + let (filter, limit) = parse_show("show data People where Age >= 35 limit 1"); let data = db .query_data("People".to_string(), filter, limit) .await @@ -14274,7 +14468,11 @@ mod tests { .await .unwrap(); assert!( - !data.rows.iter().flatten().any(|c| c.as_deref() == Some("Zed")), + !data + .rows + .iter() + .flatten() + .any(|c| c.as_deref() == Some("Zed")), "explain update must not modify rows", ); } @@ -14356,7 +14554,10 @@ mod tests { let result = db .explain_query_plan(parse_inner_adv("select * from NoSuchTable")) .await; - assert!(result.is_err(), "explaining a missing SQL table should fail"); + assert!( + result.is_err(), + "explaining a missing SQL table should fail" + ); } // --- column constraints at create-table (ADR-0029) ------ @@ -14448,7 +14649,13 @@ mod tests { vec![ col("id", Type::Serial), col("name", Type::Text), - col_c("tier", Type::Int, false, false, Some(Value::Number("3".to_string()))), + col_c( + "tier", + Type::Int, + false, + false, + Some(Value::Number("3".to_string())), + ), ], vec!["id".to_string()], None, @@ -14465,10 +14672,7 @@ mod tests { ) .await .unwrap(); - let data = db - .query_data("T".to_string(), None, None) - .await - .unwrap(); + let data = db.query_data("T".to_string(), None, None).await.unwrap(); let tier_idx = data.columns.iter().position(|c| c == "tier").unwrap(); assert_eq!( data.rows[0][tier_idx].as_deref(), @@ -14512,7 +14716,13 @@ mod tests { vec![ col("id", Type::Serial), col_c("email", Type::Text, true, true, None), - col_c("tier", Type::Int, false, false, Some(Value::Number("1".to_string()))), + col_c( + "tier", + Type::Int, + false, + false, + Some(Value::Number("1".to_string())), + ), ], vec!["id".to_string()], None, @@ -14531,7 +14741,10 @@ mod tests { .unwrap(); let desc = db.describe_table("T".to_string()).await.unwrap(); let email = desc.columns.iter().find(|c| c.name == "email").unwrap(); - assert!(email.notnull && email.unique, "email keeps NOT NULL + UNIQUE"); + assert!( + email.notnull && email.unique, + "email keeps NOT NULL + UNIQUE" + ); let tier = desc.columns.iter().find(|c| c.name == "tier").unwrap(); assert_eq!( tier.default.as_deref(), @@ -14548,7 +14761,13 @@ mod tests { people_table(&db).await; // 4 rows db.add_column( "People".to_string(), - col_c("tier", Type::Int, false, false, Some(Value::Number("1".to_string()))), + col_c( + "tier", + Type::Int, + false, + false, + Some(Value::Number("1".to_string())), + ), None, ) .await @@ -14609,7 +14828,13 @@ mod tests { people_table(&db).await; db.add_column( "People".to_string(), - col_c("tier", Type::Int, true, false, Some(Value::Number("0".to_string()))), + col_c( + "tier", + Type::Int, + true, + false, + Some(Value::Number("0".to_string())), + ), None, ) .await @@ -14632,7 +14857,13 @@ mod tests { .await .expect("a UNIQUE column with no default is fine — NULLs do not collide"); let desc = db.describe_table("People".to_string()).await.unwrap(); - assert!(desc.columns.iter().find(|c| c.name == "badge").unwrap().unique); + assert!( + desc.columns + .iter() + .find(|c| c.name == "badge") + .unwrap() + .unique + ); } #[tokio::test] @@ -14642,7 +14873,13 @@ mod tests { let result = db .add_column( "People".to_string(), - col_c("badge", Type::Text, false, true, Some(Value::Text("X".to_string()))), + col_c( + "badge", + Type::Text, + false, + true, + Some(Value::Text("X".to_string())), + ), None, ) .await; @@ -14659,7 +14896,13 @@ mod tests { let result = db .add_column( "People".to_string(), - col_c("seq", Type::Serial, false, false, Some(Value::Number("1".to_string()))), + col_c( + "seq", + Type::Serial, + false, + false, + Some(Value::Number("1".to_string())), + ), None, ) .await; @@ -14719,8 +14962,7 @@ mod tests { #[tokio::test] async fn describe_surfaces_the_check_constraint() { let db = db(); - let (n, c, pk) = - parse_create("create table T with pk age(int) check (age >= 0)"); + let (n, c, pk) = parse_create("create table T with pk age(int) check (age >= 0)"); db.create_table(n, c, pk, None).await.unwrap(); let desc = db.describe_table("T".to_string()).await.unwrap(); let age = desc.columns.iter().find(|c| c.name == "age").unwrap(); @@ -14743,7 +14985,14 @@ mod tests { .await .expect("a CHECK column adds via the rebuild path"); let desc = db.describe_table("People".to_string()).await.unwrap(); - assert!(desc.columns.iter().find(|c| c.name == "score").unwrap().check.is_some()); + assert!( + desc.columns + .iter() + .find(|c| c.name == "score") + .unwrap() + .check + .is_some() + ); // An update that violates the check is refused. let bad = db .update( @@ -14759,8 +15008,7 @@ mod tests { #[tokio::test] async fn rebuild_preserves_a_check_constraint() { let db = db(); - let (n, c, pk) = - parse_create("create table T with pk code(text) check (code like 'X%')"); + let (n, c, pk) = parse_create("create table T with pk code(text) check (code like 'X%')"); db.create_table(n, c, pk, None).await.unwrap(); db.add_column("T".to_string(), col("note", Type::Int), None) .await @@ -14778,7 +15026,12 @@ mod tests { .unwrap(); let desc = db.describe_table("T".to_string()).await.unwrap(); assert!( - desc.columns.iter().find(|c| c.name == "code").unwrap().check.is_some(), + desc.columns + .iter() + .find(|c| c.name == "code") + .unwrap() + .check + .is_some(), "code keeps its CHECK across the rebuild", ); } @@ -14809,7 +15062,8 @@ mod tests { "Customers".to_string(), None, vec![Value::Text(name.to_string())], - None) + None, + ) .await .unwrap(); } @@ -14818,7 +15072,8 @@ mod tests { "Customers".to_string(), vec![("Name".to_string(), Value::Text("X".to_string()))], RowFilter::AllRows, - None) + None, + ) .await .unwrap(); assert_eq!(result.rows_affected, 3); @@ -14834,7 +15089,8 @@ mod tests { "Customers".to_string(), None, vec![Value::Text(name.to_string())], - None) + None, + ) .await .unwrap(); } @@ -14842,12 +15098,16 @@ mod tests { .delete( "Customers".to_string(), RowFilter::eq("id", Value::Number("1".to_string())), - None) + None, + ) .await .unwrap(); assert_eq!(result.rows_affected, 1); assert!(result.cascade.is_empty(), "no children to cascade to"); - let data = db.query_data("Customers".to_string(), None, None).await.unwrap(); + let data = db + .query_data("Customers".to_string(), None, None) + .await + .unwrap(); assert_eq!(data.rows.len(), 1); assert_eq!(data.rows[0][1], Some("Bob".to_string())); } @@ -14875,7 +15135,8 @@ mod tests { "Orders".to_string(), vec![col("id", Type::Serial), col("CustId", Type::Int)], vec!["id".to_string()], - None) + None, + ) .await .unwrap(); db.add_relationship( @@ -14887,7 +15148,8 @@ mod tests { ReferentialAction::NoAction, ReferentialAction::NoAction, false, - None) + None, + ) .await .unwrap(); @@ -14896,7 +15158,8 @@ mod tests { "Orders".to_string(), Some(vec!["CustId".to_string()]), vec![Value::Number("999".to_string())], - None) + None, + ) .await .unwrap_err(); match err { @@ -14918,7 +15181,8 @@ mod tests { "Orders".to_string(), vec![col("id", Type::Serial), col("CustId", Type::Int)], vec!["id".to_string()], - None) + None, + ) .await .unwrap(); db.add_relationship( @@ -14930,31 +15194,38 @@ mod tests { ReferentialAction::Cascade, ReferentialAction::NoAction, false, - None) + None, + ) .await .unwrap(); db.insert( "Customers".to_string(), None, vec![Value::Text("Alice".to_string())], - None) + None, + ) .await .unwrap(); db.insert( "Orders".to_string(), Some(vec!["CustId".to_string()]), vec![Value::Number("1".to_string())], - None) + None, + ) .await .unwrap(); // Delete Alice — cascades to Orders. db.delete( "Customers".to_string(), RowFilter::eq("id", Value::Number("1".to_string())), - None) + None, + ) .await .unwrap(); - let orders = db.query_data("Orders".to_string(), None, None).await.unwrap(); + let orders = db + .query_data("Orders".to_string(), None, None) + .await + .unwrap(); assert!(orders.rows.is_empty(), "child rows should be cascaded"); } @@ -15001,7 +15272,10 @@ mod tests { db.insert( "T".to_string(), Some(vec!["id".to_string(), "ParentId".to_string()]), - vec![Value::Number("2".to_string()), Value::Number("1".to_string())], + vec![ + Value::Number("2".to_string()), + Value::Number("1".to_string()), + ], None, ) .await @@ -15009,7 +15283,10 @@ mod tests { db.insert( "T".to_string(), Some(vec!["id".to_string(), "ParentId".to_string()]), - vec![Value::Number("3".to_string()), Value::Number("2".to_string())], + vec![ + Value::Number("3".to_string()), + Value::Number("2".to_string()), + ], None, ) .await @@ -15022,8 +15299,15 @@ mod tests { ) .await .unwrap(); - assert_eq!(result.rows_affected, 1, "one row matched the filter directly"); - assert_eq!(result.cascade.len(), 1, "self-ref relationship reported once"); + assert_eq!( + result.rows_affected, 1, + "one row matched the filter directly" + ); + assert_eq!( + result.cascade.len(), + 1, + "self-ref relationship reported once" + ); assert_eq!( result.cascade[0].rows_changed, 2, "only the 2 cascaded rows, not the directly-deleted root too" @@ -15037,24 +15321,20 @@ mod tests { "Flags".to_string(), vec![col("id", Type::Serial), col("Active", Type::Bool)], vec!["id".to_string()], - None) - .await - .unwrap(); - db.insert( - "Flags".to_string(), None, - vec![Value::Bool(true)], - None) + ) .await .unwrap(); - db.insert( - "Flags".to_string(), - None, - vec![Value::Bool(false)], - None) - .await - .unwrap(); - let data = db.query_data("Flags".to_string(), None, None).await.unwrap(); + db.insert("Flags".to_string(), None, vec![Value::Bool(true)], None) + .await + .unwrap(); + db.insert("Flags".to_string(), None, vec![Value::Bool(false)], None) + .await + .unwrap(); + let data = db + .query_data("Flags".to_string(), None, None) + .await + .unwrap(); assert_eq!(data.rows[0][1], Some("true".to_string())); assert_eq!(data.rows[1][1], Some("false".to_string())); } @@ -15066,16 +15346,13 @@ mod tests { "T".to_string(), vec![col("id", Type::Serial), col("note", Type::Text)], vec!["id".to_string()], - None) - .await - .unwrap(); - db.insert( - "T".to_string(), None, - vec![Value::Null], - None) + ) .await .unwrap(); + db.insert("T".to_string(), None, vec![Value::Null], None) + .await + .unwrap(); let data = db.query_data("T".to_string(), None, None).await.unwrap(); assert_eq!(data.rows[0][1], None); } @@ -15090,10 +15367,8 @@ mod tests { // constraint (C3-track). let conn = Connection::open_in_memory().unwrap(); configure_connection(&conn).unwrap(); - conn.execute_batch( - "CREATE TABLE T (id INTEGER PRIMARY KEY, code TEXT UNIQUE) STRICT;", - ) - .unwrap(); + conn.execute_batch("CREATE TABLE T (id INTEGER PRIMARY KEY, code TEXT UNIQUE) STRICT;") + .unwrap(); let schema = read_schema(&conn, "T").unwrap(); let id = schema.columns.iter().find(|c| c.name == "id").unwrap(); let code = schema.columns.iter().find(|c| c.name == "code").unwrap(); @@ -15204,9 +15479,13 @@ mod tests { ) .await .unwrap(); - db.add_column("T".to_string(), ColumnSpec::new("seq".to_string(), Type::Serial), None) - .await - .unwrap(); + db.add_column( + "T".to_string(), + ColumnSpec::new("seq".to_string(), Type::Serial), + None, + ) + .await + .unwrap(); // Read the persisted YAML straight from disk. let yaml = std::fs::read_to_string(dir.path().join(PROJECT_YAML)).unwrap(); assert!( @@ -15216,10 +15495,7 @@ mod tests { // PK column not separately marked unique — PK already // implies UNIQUE, double-marking would emit redundant // DDL on rebuild. - let lines_with_id: Vec<&str> = yaml - .lines() - .filter(|l| l.contains("name: id")) - .collect(); + let lines_with_id: Vec<&str> = yaml.lines().filter(|l| l.contains("name: id")).collect(); for line in &lines_with_id { assert!( !line.contains("unique: true"), @@ -15323,9 +15599,13 @@ mod tests { ) .await .unwrap(); - db.add_column("T".to_string(), ColumnSpec::new("seq".to_string(), Type::Serial), None) - .await - .unwrap(); + db.add_column( + "T".to_string(), + ColumnSpec::new("seq".to_string(), Type::Serial), + None, + ) + .await + .unwrap(); // Tear down the .db file and rebuild from yaml + csvs. drop(db); std::fs::remove_file(&db_path).unwrap(); @@ -15382,7 +15662,8 @@ mod tests { "Order Lines".to_string(), vec![col("id", Type::Serial)], vec!["id".to_string()], - None) + None, + ) .await .unwrap(); let tables = db.list_tables().await.unwrap(); @@ -15542,7 +15823,11 @@ mod tests { .await .expect("NOT NULL applies — no row holds a null"); assert!( - desc.columns.iter().find(|c| c.name == "Name").unwrap().notnull, + desc.columns + .iter() + .find(|c| c.name == "Name") + .unwrap() + .notnull, "the column is now NOT NULL", ); } @@ -15552,7 +15837,12 @@ mod tests { let db = db(); people_with_null_column(&db).await; // `x` is null in every row let result = db - .add_constraint("People".to_string(), "x".to_string(), Constraint::NotNull, None) + .add_constraint( + "People".to_string(), + "x".to_string(), + Constraint::NotNull, + None, + ) .await; let err = result.expect_err("NOT NULL is refused — rows hold null"); assert!( @@ -15574,7 +15864,13 @@ mod tests { ) .await .expect("UNIQUE applies — every Name is distinct"); - assert!(desc.columns.iter().find(|c| c.name == "Name").unwrap().unique); + assert!( + desc.columns + .iter() + .find(|c| c.name == "Name") + .unwrap() + .unique + ); } #[tokio::test] @@ -15582,7 +15878,12 @@ mod tests { let db = db(); people_table(&db).await; // Age 35 appears for Bob and Dave let result = db - .add_constraint("People".to_string(), "Age".to_string(), Constraint::Unique, None) + .add_constraint( + "People".to_string(), + "Age".to_string(), + Constraint::Unique, + None, + ) .await; assert!( result.is_err(), @@ -15603,7 +15904,14 @@ mod tests { ) .await .expect("the CHECK applies — all rows satisfy it"); - assert!(desc.columns.iter().find(|c| c.name == "Age").unwrap().check.is_some()); + assert!( + desc.columns + .iter() + .find(|c| c.name == "Age") + .unwrap() + .check + .is_some() + ); } #[tokio::test] @@ -15618,7 +15926,10 @@ mod tests { None, ) .await; - assert!(result.is_err(), "the CHECK is refused — three rows are under 40"); + assert!( + result.is_err(), + "the CHECK is refused — three rows are under 40" + ); } #[tokio::test] @@ -15635,7 +15946,12 @@ mod tests { .await .expect("a DEFAULT applies — it never touches existing rows"); assert_eq!( - desc.columns.iter().find(|c| c.name == "Age").unwrap().default.as_deref(), + desc.columns + .iter() + .find(|c| c.name == "Age") + .unwrap() + .default + .as_deref(), Some("0"), ); } @@ -15672,9 +15988,17 @@ mod tests { let db = db(); people_table(&db).await; let result = db - .add_constraint("People".to_string(), "id".to_string(), Constraint::NotNull, None) + .add_constraint( + "People".to_string(), + "id".to_string(), + Constraint::NotNull, + None, + ) .await; - assert!(result.is_err(), "a PK column is already NOT NULL (ADR-0029 §9)"); + assert!( + result.is_err(), + "a PK column is already NOT NULL (ADR-0029 §9)" + ); } #[tokio::test] @@ -15682,7 +16006,12 @@ mod tests { let db = db(); people_table(&db).await; let result = db - .add_constraint("People".to_string(), "id".to_string(), Constraint::Unique, None) + .add_constraint( + "People".to_string(), + "id".to_string(), + Constraint::Unique, + None, + ) .await; assert!( result.is_err(), @@ -15733,7 +16062,12 @@ mod tests { let db = db(); people_table(&db).await; let result = db - .add_constraint("People".to_string(), "ghost".to_string(), Constraint::Unique, None) + .add_constraint( + "People".to_string(), + "ghost".to_string(), + Constraint::Unique, + None, + ) .await; assert!(result.is_err(), "no such column"); } @@ -15760,7 +16094,12 @@ mod tests { .await .expect("the NOT NULL is dropped"); assert!( - !desc.columns.iter().find(|c| c.name == "Name").unwrap().notnull, + !desc + .columns + .iter() + .find(|c| c.name == "Name") + .unwrap() + .notnull, "the column is nullable again", ); } @@ -15787,7 +16126,12 @@ mod tests { .await .expect("the CHECK is dropped"); assert!( - desc.columns.iter().find(|c| c.name == "Age").unwrap().check.is_none(), + desc.columns + .iter() + .find(|c| c.name == "Age") + .unwrap() + .check + .is_none(), "the CHECK is gone from the structure view", ); // With the CHECK gone, a previously-forbidden value inserts. diff --git a/src/dsl/command.rs b/src/dsl/command.rs index db0e23e..c50670f 100644 --- a/src/dsl/command.rs +++ b/src/dsl/command.rs @@ -549,9 +549,7 @@ pub enum AppCommand { /// word like `insert` / `create` / `show`, or `types`), the /// focused detail for that command (or command group sharing /// the entry word). - Help { - topic: Option, - }, + Help { topic: Option }, /// 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 @@ -580,7 +578,10 @@ pub enum AppCommand { /// Unpack a zip into a new project and switch to it. /// `target` overrides the project name (default: taken from /// the zip). - Import { path: String, target: Option }, + Import { + path: String, + target: Option, + }, /// Switch the persistent input mode. Mode { value: ModeValue }, /// Show or set the messages verbosity. @@ -791,9 +792,7 @@ impl PartialEq for Operand { fn eq(&self, other: &Self) -> bool { match (self, other) { (Self::Column { name: a, .. }, Self::Column { name: b, .. }) => a == b, - (Self::Literal { value: a, .. }, Self::Literal { value: b, .. }) => { - a == b - } + (Self::Literal { value: a, .. }, Self::Literal { value: b, .. }) => a == b, _ => false, } } @@ -817,7 +816,9 @@ pub enum CompareOp { /// a single row in the metadata table. #[derive(Debug, Clone, PartialEq, Eq)] pub enum RelationshipSelector { - Named { name: String }, + Named { + name: String, + }, Endpoints { parent_table: String, parent_column: String, @@ -1156,9 +1157,7 @@ impl Command { parent_column, child_table, child_column, - } => format!( - "from {parent_table}.{parent_column} to {child_table}.{child_column}" - ), + } => format!("from {parent_table}.{parent_column} to {child_table}.{child_column}"), }, // A constraint command's subject is the dotted // `
.` it acts on (ADR-0029 §2.2). diff --git a/src/dsl/grammar/app.rs b/src/dsl/grammar/app.rs index a4f116f..b5653e0 100644 --- a/src/dsl/grammar/app.rs +++ b/src/dsl/grammar/app.rs @@ -9,8 +9,7 @@ use crate::dsl::command::{AppCommand, Command, CopyScope, MessagesValue, ModeValue}; use crate::dsl::grammar::{ - CommandNode, HintMode, IdentSource, IdentValidator, Node, ValidationError, - Word, + CommandNode, HintMode, IdentSource, IdentValidator, Node, ValidationError, Word, }; use crate::dsl::walker::outcome::{MatchedKind, MatchedPath}; @@ -60,19 +59,16 @@ const IMPORT_TARGET_IDENT: Node = Node::Ident { writes_table: false, writes_column: false, writes_user_listed_column: false, -writes_table_alias: false, -writes_cte_name: false, -writes_projection_alias: false, + writes_table_alias: false, + writes_cte_name: false, + writes_projection_alias: false, }; const IMPORT_TARGET: Node = Node::Hinted { mode: HintMode::ForceProse("hint.ambient_typing_name"), inner: &IMPORT_TARGET_IDENT, }; -const IMPORT_AS_TARGET: Node = Node::Seq(&[ - Node::Word(Word::keyword("as")), - IMPORT_TARGET, -]); +const IMPORT_AS_TARGET: Node = Node::Seq(&[Node::Word(Word::keyword("as")), IMPORT_TARGET]); const IMPORT_AS_TARGET_OPT: Node = Node::Optional(&IMPORT_AS_TARGET); const IMPORT_PATH_AND_TARGET: Node = Node::Seq(&[Node::BarePath, IMPORT_AS_TARGET_OPT]); @@ -101,9 +97,9 @@ const MODE_CHOICES: &[Node] = &[ writes_table: false, writes_column: false, writes_user_listed_column: false, - writes_table_alias: false, - writes_cte_name: false, - writes_projection_alias: false, + writes_table_alias: false, + writes_cte_name: false, + writes_projection_alias: false, }, ]; const MODE_VALUE: Node = Node::Choice(MODE_CHOICES); @@ -119,9 +115,9 @@ const MESSAGES_CHOICES: &[Node] = &[ writes_table: false, writes_column: false, writes_user_listed_column: false, - writes_table_alias: false, - writes_cte_name: false, - writes_projection_alias: false, + writes_table_alias: false, + writes_cte_name: false, + writes_projection_alias: false, }, ]; const MESSAGES_VALUE: Node = Node::Choice(MESSAGES_CHOICES); @@ -271,7 +267,8 @@ pub static QUIT: CommandNode = CommandNode { ast_builder: build_quit, help_id: Some("app.quit"), hint_ids: &["quit"], - usage_ids: &["parse.usage.quit"],}; + usage_ids: &["parse.usage.quit"], +}; pub static HELP: CommandNode = CommandNode { entry: Word::keyword("help"), @@ -279,7 +276,8 @@ pub static HELP: CommandNode = CommandNode { ast_builder: build_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"), @@ -288,7 +286,8 @@ pub static HINT: CommandNode = CommandNode { 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"],}; + usage_ids: &["parse.usage.hint"], +}; pub static REBUILD: CommandNode = CommandNode { entry: Word::keyword("rebuild"), @@ -296,7 +295,8 @@ pub static REBUILD: CommandNode = CommandNode { ast_builder: build_rebuild, help_id: Some("app.rebuild"), hint_ids: &["rebuild"], - usage_ids: &["parse.usage.rebuild"],}; + usage_ids: &["parse.usage.rebuild"], +}; pub static VERSION: CommandNode = CommandNode { entry: Word::keyword("version"), @@ -304,7 +304,8 @@ pub static VERSION: CommandNode = CommandNode { ast_builder: build_version, help_id: Some("app.version"), hint_ids: &["version"], - usage_ids: &["parse.usage.version"],}; + usage_ids: &["parse.usage.version"], +}; pub static SAVE: CommandNode = CommandNode { entry: Word::keyword("save"), @@ -312,7 +313,8 @@ pub static SAVE: CommandNode = CommandNode { ast_builder: build_save, help_id: Some("app.save"), hint_ids: &["save"], - usage_ids: &["parse.usage.save"],}; + usage_ids: &["parse.usage.save"], +}; pub static NEW: CommandNode = CommandNode { entry: Word::keyword("new"), @@ -320,7 +322,8 @@ pub static NEW: CommandNode = CommandNode { ast_builder: build_new, help_id: Some("app.new"), hint_ids: &["new"], - usage_ids: &["parse.usage.new"],}; + usage_ids: &["parse.usage.new"], +}; pub static LOAD: CommandNode = CommandNode { entry: Word::keyword("load"), @@ -328,7 +331,8 @@ pub static LOAD: CommandNode = CommandNode { ast_builder: build_load, help_id: Some("app.load"), hint_ids: &["load"], - usage_ids: &["parse.usage.load"],}; + usage_ids: &["parse.usage.load"], +}; pub static EXPORT: CommandNode = CommandNode { entry: Word::keyword("export"), @@ -336,7 +340,8 @@ pub static EXPORT: CommandNode = CommandNode { ast_builder: build_export, help_id: Some("app.export"), hint_ids: &["export"], - usage_ids: &["parse.usage.export"],}; + usage_ids: &["parse.usage.export"], +}; pub static IMPORT: CommandNode = CommandNode { entry: Word::keyword("import"), @@ -344,7 +349,8 @@ pub static IMPORT: CommandNode = CommandNode { ast_builder: build_import, help_id: Some("app.import"), hint_ids: &["import"], - usage_ids: &["parse.usage.import"],}; + usage_ids: &["parse.usage.import"], +}; pub static MODE: CommandNode = CommandNode { entry: Word::keyword("mode"), @@ -352,7 +358,8 @@ pub static MODE: CommandNode = CommandNode { ast_builder: build_mode, help_id: Some("app.mode"), hint_ids: &["mode"], - usage_ids: &["parse.usage.mode"],}; + usage_ids: &["parse.usage.mode"], +}; pub static MESSAGES: CommandNode = CommandNode { entry: Word::keyword("messages"), @@ -360,7 +367,8 @@ pub static MESSAGES: CommandNode = CommandNode { ast_builder: build_messages, help_id: Some("app.messages"), hint_ids: &["messages"], - usage_ids: &["parse.usage.messages"],}; + usage_ids: &["parse.usage.messages"], +}; pub static UNDO: CommandNode = CommandNode { entry: Word::keyword("undo"), @@ -368,7 +376,8 @@ pub static UNDO: CommandNode = CommandNode { ast_builder: build_undo, help_id: Some("app.undo"), hint_ids: &["undo"], - usage_ids: &["parse.usage.undo"],}; + usage_ids: &["parse.usage.undo"], +}; pub static REDO: CommandNode = CommandNode { entry: Word::keyword("redo"), @@ -376,7 +385,8 @@ pub static REDO: CommandNode = CommandNode { ast_builder: build_redo, help_id: Some("app.redo"), hint_ids: &["redo"], - usage_ids: &["parse.usage.redo"],}; + usage_ids: &["parse.usage.redo"], +}; pub static COPY: CommandNode = CommandNode { entry: Word::keyword("copy"), @@ -384,4 +394,5 @@ pub static COPY: CommandNode = CommandNode { ast_builder: build_copy, help_id: Some("app.copy"), hint_ids: &["copy"], - usage_ids: &["parse.usage.copy"],}; + usage_ids: &["parse.usage.copy"], +}; diff --git a/src/dsl/grammar/data.rs b/src/dsl/grammar/data.rs index 6441e24..7e17eca 100644 --- a/src/dsl/grammar/data.rs +++ b/src/dsl/grammar/data.rs @@ -24,19 +24,17 @@ //! later swap that capture for the same typed slots used here, adding //! live hints/highlighting. -use crate::dsl::command::{ - Command, Expr, RowFilter, SeedOverride, SeedOverrideKind, ShowListKind, -}; +use crate::dsl::command::{Command, Expr, RowFilter, SeedOverride, SeedOverrideKind, ShowListKind}; use crate::dsl::grammar::{ CommandNode, IdentSource, Node, NumberValidator, ValidationError, Word, expr, shared::{ - FALLBACK_VALUE_LIST, column_value_list, count_tuple_values, - current_column_value, insert_target_columns, + FALLBACK_VALUE_LIST, column_value_list, count_tuple_values, current_column_value, + insert_target_columns, }, sql_delete, sql_insert, sql_select, sql_update, }; -use crate::dsl::walker::context::WalkContext; use crate::dsl::value::Value; +use crate::dsl::walker::context::WalkContext; use crate::dsl::walker::outcome::{MatchedItem, MatchedKind, MatchedPath}; // ================================================================= @@ -56,10 +54,10 @@ const TABLE_NAME_EXISTING: Node = Node::Ident { highlight_override: None, writes_table: false, writes_column: false, - writes_user_listed_column: false, -writes_table_alias: false, -writes_cte_name: false, -writes_projection_alias: false, + writes_user_listed_column: false, + writes_table_alias: false, + writes_cte_name: false, + writes_projection_alias: false, }; /// Table-name slot variant that populates @@ -75,10 +73,10 @@ const TABLE_NAME_INSERT: Node = Node::Ident { highlight_override: None, writes_table: true, writes_column: false, - writes_user_listed_column: false, -writes_table_alias: false, -writes_cte_name: false, -writes_projection_alias: false, + writes_user_listed_column: false, + writes_table_alias: false, + writes_cte_name: false, + writes_projection_alias: false, }; // ================================================================= @@ -95,10 +93,7 @@ const SHOW_DATA_NODES: &[Node] = &[ ]; const SHOW_DATA: Node = Node::Seq(SHOW_DATA_NODES); -const SHOW_TABLE_NODES: &[Node] = &[ - Node::Word(Word::keyword("table")), - TABLE_NAME_EXISTING, -]; +const SHOW_TABLE_NODES: &[Node] = &[Node::Word(Word::keyword("table")), TABLE_NAME_EXISTING]; const SHOW_TABLE: Node = Node::Seq(SHOW_TABLE_NODES); // `show tables` / `show relationships` / `show indexes` — the @@ -144,8 +139,7 @@ const SHOW_INDEX_NAME: Node = Node::Ident { writes_cte_name: false, writes_projection_alias: false, }; -const SHOW_INDEX_NODES: &[Node] = - &[Node::Word(Word::keyword("index")), SHOW_INDEX_NAME]; +const SHOW_INDEX_NODES: &[Node] = &[Node::Word(Word::keyword("index")), SHOW_INDEX_NAME]; const SHOW_INDEX: Node = Node::Seq(SHOW_INDEX_NODES); const SHOW_CHOICES: &[Node] = &[ @@ -192,9 +186,9 @@ static FORM_A_COLUMN: Node = Node::Ident { writes_table: false, writes_column: false, writes_user_listed_column: true, -writes_table_alias: false, -writes_cte_name: false, -writes_projection_alias: false, + writes_table_alias: false, + writes_cte_name: false, + writes_projection_alias: false, }; static INSERT_COMMA: Node = Node::Punct(','); @@ -224,8 +218,7 @@ fn insert_first_paren(ctx: &WalkContext, source: &str, pos: usize) -> Node { /// or an identifier-shaped token (a column name) returns false. fn first_paren_item_is_value_literal(source: &str, pos: usize) -> bool { use crate::dsl::walker::lex_helpers::{ - consume_ident, consume_number_literal, consume_string_literal, - skip_whitespace, + consume_ident, consume_number_literal, consume_string_literal, skip_whitespace, }; let p = skip_whitespace(source, pos); if p >= source.len() { @@ -281,7 +274,11 @@ fn dsl_insert_value_list(ctx: &WalkContext, source: &str, pos: usize) -> Node { return FALLBACK_VALUE_LIST; }; let (count, closed) = count_tuple_values(source, pos); - let arity_ok = if closed { count == cols.len() } else { count <= cols.len() }; + let arity_ok = if closed { + count == cols.len() + } else { + count <= cols.len() + }; if arity_ok { Node::DynamicSubgrammar(column_value_list) } else { @@ -320,8 +317,7 @@ const INSERT_VALUES_KEYWORD_FIRST_NODES: &[Node] = &[ ]; const INSERT_VALUES_KEYWORD_FIRST: Node = Node::Seq(INSERT_VALUES_KEYWORD_FIRST_NODES); -const INSERT_AFTER_TABLE_CHOICES: &[Node] = - &[INSERT_VALUES_KEYWORD_FIRST, INSERT_PAREN_FIRST]; +const INSERT_AFTER_TABLE_CHOICES: &[Node] = &[INSERT_VALUES_KEYWORD_FIRST, INSERT_PAREN_FIRST]; const INSERT_AFTER_TABLE: Node = Node::Choice(INSERT_AFTER_TABLE_CHOICES); const INSERT_NODES: &[Node] = &[ @@ -349,10 +345,10 @@ const TABLE_NAME_WRITES: Node = Node::Ident { highlight_override: None, writes_table: true, writes_column: false, - writes_user_listed_column: false, -writes_table_alias: false, -writes_cte_name: false, -writes_projection_alias: false, + writes_user_listed_column: false, + writes_table_alias: false, + writes_cte_name: false, + writes_projection_alias: false, }; /// Column-name slot in `set col = …` — resolves the column's @@ -366,9 +362,9 @@ const SET_COLUMN: Node = Node::Ident { writes_table: false, writes_column: true, writes_user_listed_column: false, -writes_table_alias: false, -writes_cte_name: false, -writes_projection_alias: false, + writes_table_alias: false, + writes_cte_name: false, + writes_projection_alias: false, }; /// Value slot resolved at walk time from @@ -376,11 +372,7 @@ writes_projection_alias: false, /// value-literal choice when no current_column is bound. const PER_COLUMN_VALUE: Node = Node::DynamicSubgrammar(current_column_value); -const UPDATE_ASSIGNMENT_NODES: &[Node] = &[ - SET_COLUMN, - Node::Punct('='), - PER_COLUMN_VALUE, -]; +const UPDATE_ASSIGNMENT_NODES: &[Node] = &[SET_COLUMN, Node::Punct('='), PER_COLUMN_VALUE]; const UPDATE_ASSIGNMENT: Node = Node::Seq(UPDATE_ASSIGNMENT_NODES); const UPDATE_ASSIGNMENTS: Node = Node::Repeated { inner: &UPDATE_ASSIGNMENT, @@ -568,8 +560,7 @@ const SEED_OVERRIDES: Node = Node::Repeated { separator: Some(&Node::Punct(',')), min: 1, }; -const SEED_SET_CLAUSE_NODES: &[Node] = - &[Node::Word(Word::keyword("set")), SEED_OVERRIDES]; +const SEED_SET_CLAUSE_NODES: &[Node] = &[Node::Word(Word::keyword("set")), SEED_OVERRIDES]; const SEED_SET_CLAUSE: Node = Node::Seq(SEED_SET_CLAUSE_NODES); const SEED_NODES: &[Node] = &[ @@ -980,7 +971,10 @@ fn parse_seed_override_tail( MatchedKind::Word("in") => { *i += 1; // `in` // `(` - if matches!(region.get(*i).map(|t| &t.kind), Some(MatchedKind::Punct('('))) { + if matches!( + region.get(*i).map(|t| &t.kind), + Some(MatchedKind::Punct('(')) + ) { *i += 1; } let mut values = Vec::new(); @@ -1001,7 +995,10 @@ fn parse_seed_override_tail( MatchedKind::Word("between") => { *i += 1; // `between` let low = seed_take_value(region, i, column)?; - if matches!(region.get(*i).map(|t| &t.kind), Some(MatchedKind::Word("and"))) { + if matches!( + region.get(*i).map(|t| &t.kind), + Some(MatchedKind::Word("and")) + ) { *i += 1; } let high = seed_take_value(region, i, column)?; @@ -1011,7 +1008,15 @@ fn parse_seed_override_tail( *i += 1; // `as` let gen_item = region .get(*i) - .filter(|t| matches!(t.kind, MatchedKind::Ident { role: "seed_generator", .. })) + .filter(|t| { + matches!( + t.kind, + MatchedKind::Ident { + role: "seed_generator", + .. + } + ) + }) .ok_or_else(|| seed_set_error(column))?; *i += 1; Ok(SeedOverrideKind::Generator(gen_item.text.clone())) @@ -1085,7 +1090,15 @@ fn build_insert(path: &MatchedPath, _source: &str) -> Result Result Result Result, ValidationError> { +fn collect_assignments(path: &MatchedPath) -> Result, ValidationError> { let mut out = Vec::new(); let mut iter = path.items.iter(); while let Some(item) = iter.next() { @@ -1495,9 +1509,7 @@ fn build_sql_insert(path: &MatchedPath, source: &str) -> Result { + Command::ShowData { + name, + filter, + limit, + } => { assert_eq!(name, "Customers"); assert!(filter.is_some(), "where clause should survive"); assert_eq!(limit, Some(5)); @@ -2052,9 +2077,7 @@ mod explain_tests { /// Advanced-mode counterpart of `explain_inner`. fn explain_inner_adv(input: &str) -> Command { - match parse_command_in_mode(input, Mode::Advanced) - .expect("advanced explain should parse") - { + match parse_command_in_mode(input, Mode::Advanced).expect("advanced explain should parse") { Command::Explain { query } => *query, other => panic!("expected Command::Explain, got {other:?}"), } @@ -2085,7 +2108,9 @@ mod explain_tests { #[test] fn explain_sql_insert_wraps_a_sql_insert() { match explain_inner_adv("explain insert into Customers values (1, 'Bo')") { - Command::SqlInsert { sql, target_table, .. } => { + Command::SqlInsert { + sql, target_table, .. + } => { assert_eq!(target_table, "Customers"); assert_eq!(sql, "insert into Customers values (1, 'Bo')"); } @@ -2096,7 +2121,9 @@ mod explain_tests { #[test] fn explain_sql_update_wraps_a_sql_update_with_clean_sql() { match explain_inner_adv("explain update Customers set Name = 'Bo' where id = 1") { - Command::SqlUpdate { sql, target_table, .. } => { + Command::SqlUpdate { + sql, target_table, .. + } => { assert_eq!(target_table, "Customers"); assert_eq!(sql, "update Customers set Name = 'Bo' where id = 1"); } @@ -2107,7 +2134,9 @@ mod explain_tests { #[test] fn explain_sql_delete_wraps_a_sql_delete() { match explain_inner_adv("explain delete from Customers where id = 1") { - Command::SqlDelete { sql, target_table, .. } => { + Command::SqlDelete { + sql, target_table, .. + } => { assert_eq!(target_table, "Customers"); assert_eq!(sql, "delete from Customers where id = 1"); } @@ -2148,11 +2177,7 @@ mod explain_tests { fn explain_does_not_cover_ddl() { // EXPLAIN QUERY PLAN applies to DML/queries only (ADR-0039 // out of scope); there is no SQL DDL branch under explain. - assert!(parse_command_in_mode( - "explain create table T (id int)", - Mode::Advanced, - ) - .is_err()); + assert!(parse_command_in_mode("explain create table T (id int)", Mode::Advanced,).is_err()); } #[test] @@ -2165,9 +2190,8 @@ mod explain_tests { use crate::completion::candidates_at_cursor_in_mode; let schema = crate::completion::SchemaCache::default(); let input = "explain "; - let completion = - candidates_at_cursor_in_mode(input, input.len(), &schema, Mode::Advanced) - .expect("explain offers candidates"); + let completion = candidates_at_cursor_in_mode(input, input.len(), &schema, Mode::Advanced) + .expect("explain offers candidates"); let names: Vec<&str> = completion .candidates .iter() @@ -2178,4 +2202,3 @@ mod explain_tests { } } } - diff --git a/src/dsl/grammar/ddl.rs b/src/dsl/grammar/ddl.rs index 83839d8..cb4a680 100644 --- a/src/dsl/grammar/ddl.rs +++ b/src/dsl/grammar/ddl.rs @@ -16,11 +16,11 @@ use crate::dsl::command::{ AlterTableAction, ChangeColumnMode, ColumnSpec, Command, Constraint, ConstraintKind, Expr, IndexSelector, RelationshipSelector, SqlForeignKey, TableConstraint, }; -use crate::dsl::value::Value; use crate::dsl::grammar::{ CommandNode, HighlightClass, HintMode, IdentSource, Node, ValidationError, Word, shared::{REFERENTIAL_CLAUSES, TYPE_SLOT, TYPE_VALIDATOR}, }; +use crate::dsl::value::Value; /// `HintMode` annotation shared by every `NewName` ident slot: /// the user is inventing a name, so the hint panel forces the @@ -39,12 +39,12 @@ const TABLE_NAME_NEW_IDENT: Node = Node::Ident { role: "table_name", validator: None, highlight_override: None, - writes_table: false, - writes_column: false, - writes_user_listed_column: false, -writes_table_alias: false, -writes_cte_name: false, -writes_projection_alias: false, + writes_table: false, + writes_column: false, + writes_user_listed_column: false, + writes_table_alias: false, + writes_cte_name: false, + writes_projection_alias: false, }; const TABLE_NAME_NEW: Node = Node::Hinted { mode: NEW_NAME_HINT, @@ -63,12 +63,12 @@ const TABLE_NAME_EXISTING: Node = Node::Ident { role: "table_name", validator: None, highlight_override: None, - writes_table: true, - writes_column: false, - writes_user_listed_column: false, -writes_table_alias: false, -writes_cte_name: false, -writes_projection_alias: false, + writes_table: true, + writes_column: false, + writes_user_listed_column: false, + writes_table_alias: false, + writes_cte_name: false, + writes_projection_alias: false, }; const COLUMN_NAME: Node = Node::Ident { @@ -76,12 +76,12 @@ const COLUMN_NAME: Node = Node::Ident { role: "column_name", validator: None, highlight_override: None, - writes_table: false, - writes_column: false, - writes_user_listed_column: false, -writes_table_alias: false, -writes_cte_name: false, -writes_projection_alias: false, + writes_table: false, + writes_column: false, + writes_user_listed_column: false, + writes_table_alias: false, + writes_cte_name: false, + writes_projection_alias: false, }; const COLUMN_NAME_NEW_IDENT: Node = Node::Ident { @@ -89,12 +89,12 @@ const COLUMN_NAME_NEW_IDENT: Node = Node::Ident { role: "column_name", validator: None, highlight_override: None, - writes_table: false, - writes_column: false, - writes_user_listed_column: false, -writes_table_alias: false, -writes_cte_name: false, -writes_projection_alias: false, + writes_table: false, + writes_column: false, + writes_user_listed_column: false, + writes_table_alias: false, + writes_cte_name: false, + writes_projection_alias: false, }; const COLUMN_NAME_NEW: Node = Node::Hinted { mode: NEW_NAME_HINT, @@ -106,12 +106,12 @@ const RELATIONSHIP_NAME: Node = Node::Ident { role: "relationship_name", validator: None, highlight_override: None, - writes_table: false, - writes_column: false, - writes_user_listed_column: false, -writes_table_alias: false, -writes_cte_name: false, -writes_projection_alias: false, + writes_table: false, + writes_column: false, + writes_user_listed_column: false, + writes_table_alias: false, + writes_cte_name: false, + writes_projection_alias: false, }; const RELATIONSHIP_NAME_NEW_IDENT: Node = Node::Ident { @@ -119,12 +119,12 @@ const RELATIONSHIP_NAME_NEW_IDENT: Node = Node::Ident { role: "relationship_name", validator: None, highlight_override: None, - writes_table: false, - writes_column: false, - writes_user_listed_column: false, -writes_table_alias: false, -writes_cte_name: false, -writes_projection_alias: false, + writes_table: false, + writes_column: false, + writes_user_listed_column: false, + writes_table_alias: false, + writes_cte_name: false, + writes_projection_alias: false, }; const RELATIONSHIP_NAME_NEW: Node = Node::Hinted { mode: NEW_NAME_HINT, @@ -139,9 +139,9 @@ const INDEX_NAME_EXISTING: Node = Node::Ident { writes_table: false, writes_column: false, writes_user_listed_column: false, -writes_table_alias: false, -writes_cte_name: false, -writes_projection_alias: false, + writes_table_alias: false, + writes_cte_name: false, + writes_projection_alias: false, }; const INDEX_NAME_NEW_IDENT: Node = Node::Ident { @@ -152,9 +152,9 @@ const INDEX_NAME_NEW_IDENT: Node = Node::Ident { writes_table: false, writes_column: false, writes_user_listed_column: false, -writes_table_alias: false, -writes_cte_name: false, -writes_projection_alias: false, + writes_table_alias: false, + writes_cte_name: false, + writes_projection_alias: false, }; const INDEX_NAME_NEW: Node = Node::Hinted { mode: NEW_NAME_HINT, @@ -181,10 +181,7 @@ const TABLE_OPT: Node = Node::Optional(&Node::Word(Word::keyword("table"))); // drop_table — `drop table ` // ================================================================= -const DROP_TABLE_NODES: &[Node] = &[ - Node::Word(Word::keyword("table")), - TABLE_NAME_EXISTING, -]; +const DROP_TABLE_NODES: &[Node] = &[Node::Word(Word::keyword("table")), TABLE_NAME_EXISTING]; const DROP_TABLE: Node = Node::Seq(DROP_TABLE_NODES); // Advanced-mode SQL `DROP TABLE [IF EXISTS] [;]` (ADR-0035 §4, @@ -192,8 +189,10 @@ const DROP_TABLE: Node = Node::Seq(DROP_TABLE_NODES); // plus the optional `IF EXISTS` no-op-with-note. The leading concrete // `table` keyword (not the Optional) keeps the element/dispatch // matching honest. -static SQL_DROP_IF_EXISTS_NODES: &[Node] = - &[Node::Word(Word::keyword("if")), Node::Word(Word::keyword("exists"))]; +static SQL_DROP_IF_EXISTS_NODES: &[Node] = &[ + Node::Word(Word::keyword("if")), + Node::Word(Word::keyword("exists")), +]; const SQL_DROP_IF_EXISTS_OPT: Node = Node::Optional(&Node::Seq(SQL_DROP_IF_EXISTS_NODES)); static SQL_DROP_TABLE_SHAPE_NODES: &[Node] = &[ Node::Word(Word::keyword("table")), @@ -257,9 +256,9 @@ const DR_PARENT_NODES: &[Node] = &[ writes_table: true, writes_column: false, writes_user_listed_column: false, - writes_table_alias: false, - writes_cte_name: false, - writes_projection_alias: false, + writes_table_alias: false, + writes_cte_name: false, + writes_projection_alias: false, }, Node::Punct('.'), Node::Ident { @@ -270,9 +269,9 @@ const DR_PARENT_NODES: &[Node] = &[ writes_table: false, writes_column: false, writes_user_listed_column: false, - writes_table_alias: false, - writes_cte_name: false, - writes_projection_alias: false, + writes_table_alias: false, + writes_cte_name: false, + writes_projection_alias: false, }, ]; const DR_PARENT: Node = Node::Seq(DR_PARENT_NODES); @@ -286,9 +285,9 @@ const DR_CHILD_NODES: &[Node] = &[ writes_table: true, writes_column: false, writes_user_listed_column: false, - writes_table_alias: false, - writes_cte_name: false, - writes_projection_alias: false, + writes_table_alias: false, + writes_cte_name: false, + writes_projection_alias: false, }, Node::Punct('.'), Node::Ident { @@ -299,9 +298,9 @@ const DR_CHILD_NODES: &[Node] = &[ writes_table: false, writes_column: false, writes_user_listed_column: false, - writes_table_alias: false, - writes_cte_name: false, - writes_projection_alias: false, + writes_table_alias: false, + writes_cte_name: false, + writes_projection_alias: false, }, ]; const DR_CHILD: Node = Node::Seq(DR_CHILD_NODES); @@ -317,10 +316,7 @@ const DR_ENDPOINTS: Node = Node::Seq(DR_ENDPOINTS_NODES); const DR_SELECTOR_CHOICES: &[Node] = &[DR_ENDPOINTS, RELATIONSHIP_NAME]; const DR_SELECTOR: Node = Node::Choice(DR_SELECTOR_CHOICES); -const DROP_RELATIONSHIP_NODES: &[Node] = &[ - Node::Word(Word::keyword("relationship")), - DR_SELECTOR, -]; +const DROP_RELATIONSHIP_NODES: &[Node] = &[Node::Word(Word::keyword("relationship")), DR_SELECTOR]; const DROP_RELATIONSHIP: Node = Node::Seq(DROP_RELATIONSHIP_NODES); // ================================================================= @@ -341,18 +337,20 @@ const DI_POSITIONAL: Node = Node::Seq(DI_POSITIONAL_NODES); const DI_SELECTOR_CHOICES: &[Node] = &[DI_POSITIONAL, INDEX_NAME_EXISTING]; const DI_SELECTOR: Node = Node::Choice(DI_SELECTOR_CHOICES); -const DROP_INDEX_NODES: &[Node] = &[ - Node::Word(Word::keyword("index")), - DI_SELECTOR, -]; +const DROP_INDEX_NODES: &[Node] = &[Node::Word(Word::keyword("index")), DI_SELECTOR]; const DROP_INDEX: Node = Node::Seq(DROP_INDEX_NODES); // ================================================================= // drop entry — `drop (table|column|relationship|index) ...` // ================================================================= -const DROP_CHOICES: &[Node] = - &[DROP_COLUMN, DROP_RELATIONSHIP, DROP_TABLE, DROP_INDEX, DROP_CONSTRAINT]; +const DROP_CHOICES: &[Node] = &[ + DROP_COLUMN, + DROP_RELATIONSHIP, + DROP_TABLE, + DROP_INDEX, + DROP_CONSTRAINT, +]; const DROP_SHAPE: Node = Node::Choice(DROP_CHOICES); // ================================================================= @@ -450,8 +448,7 @@ const AR_CHILD_COL_LIST: Node = Node::Repeated { separator: Some(&Node::Punct(',')), min: 1, }; -const AR_CHILD_COLS_PAREN_NODES: &[Node] = - &[Node::Punct('('), AR_CHILD_COL_LIST, Node::Punct(')')]; +const AR_CHILD_COLS_PAREN_NODES: &[Node] = &[Node::Punct('('), AR_CHILD_COL_LIST, Node::Punct(')')]; const AR_CHILD_COLS_PAREN: Node = Node::Seq(AR_CHILD_COLS_PAREN_NODES); const AR_CHILD_COLS_CHOICES: &[Node] = &[AR_CHILD_COLS_PAREN, AR_CHILD_COL]; const AR_CHILD_COLS: Node = Node::Choice(AR_CHILD_COLS_CHOICES); @@ -474,10 +471,7 @@ const AR_CHILD_NODES: &[Node] = &[ ]; const AR_CHILD: Node = Node::Seq(AR_CHILD_NODES); -const AR_AS_NAME_NODES: &[Node] = &[ - Node::Word(Word::keyword("as")), - RELATIONSHIP_NAME_NEW, -]; +const AR_AS_NAME_NODES: &[Node] = &[Node::Word(Word::keyword("as")), RELATIONSHIP_NAME_NEW]; const AR_AS_NAME_OPT: Node = Node::Optional(&Node::Seq(AR_AS_NAME_NODES)); const AR_CREATE_FK_OPT: Node = Node::Optional(&Node::Flag("create-fk")); @@ -501,10 +495,7 @@ const ADD_RELATIONSHIP: Node = Node::Seq(ADD_RELATIONSHIP_NODES); // add_index — `add index [as ] on (, …)` // ================================================================= -const AI_AS_NAME_NODES: &[Node] = &[ - Node::Word(Word::keyword("as")), - INDEX_NAME_NEW, -]; +const AI_AS_NAME_NODES: &[Node] = &[Node::Word(Word::keyword("as")), INDEX_NAME_NEW]; const AI_AS_NAME_OPT: Node = Node::Optional(&Node::Seq(AI_AS_NAME_NODES)); const ADD_INDEX_NODES: &[Node] = &[ @@ -537,9 +528,9 @@ const NEW_COLUMN_NAME_IDENT: Node = Node::Ident { writes_table: false, writes_column: false, writes_user_listed_column: false, -writes_table_alias: false, -writes_cte_name: false, -writes_projection_alias: false, + writes_table_alias: false, + writes_cte_name: false, + writes_projection_alias: false, }; const NEW_COLUMN_NAME: Node = Node::Hinted { mode: NEW_NAME_HINT, @@ -563,10 +554,7 @@ const RENAME_COLUMN: Node = Node::Seq(RENAME_COLUMN_NODES); // ( ) [--force-conversion | --dont-convert]` // ================================================================= -const CHANGE_FLAG_CHOICES: &[Node] = &[ - Node::Flag("force-conversion"), - Node::Flag("dont-convert"), -]; +const CHANGE_FLAG_CHOICES: &[Node] = &[Node::Flag("force-conversion"), Node::Flag("dont-convert")]; const CHANGE_FLAG_OPT: Node = Node::Repeated { inner: &Node::Choice(CHANGE_FLAG_CHOICES), separator: None, @@ -732,8 +720,7 @@ fn build_add(path: &MatchedPath, _source: &str) -> Result Result [with pk [()[, ...]]]` @@ -1034,9 +1028,9 @@ const COL_NAME_IDENT: Node = Node::Ident { writes_table: false, writes_column: false, writes_user_listed_column: false, -writes_table_alias: false, -writes_cte_name: false, -writes_projection_alias: false, + writes_table_alias: false, + writes_cte_name: false, + writes_projection_alias: false, }; const COL_NAME: Node = Node::Hinted { mode: NEW_NAME_HINT, @@ -1074,8 +1068,12 @@ const CHECK_CONSTRAINT_NODES: &[Node] = &[ ]; const CHECK_CONSTRAINT: Node = Node::Seq(CHECK_CONSTRAINT_NODES); -const COLUMN_CONSTRAINT_CHOICES: &[Node] = - &[NOT_NULL_CONSTRAINT, UNIQUE_CONSTRAINT, DEFAULT_CONSTRAINT, CHECK_CONSTRAINT]; +const COLUMN_CONSTRAINT_CHOICES: &[Node] = &[ + NOT_NULL_CONSTRAINT, + UNIQUE_CONSTRAINT, + DEFAULT_CONSTRAINT, + CHECK_CONSTRAINT, +]; const COLUMN_CONSTRAINT: Node = Node::Choice(COLUMN_CONSTRAINT_CHOICES); /// Zero-or-more constraints — the suffix after a column's @@ -1114,8 +1112,7 @@ const DROP_CONSTRAINT_KIND: Node = Node::Choice(DROP_CONSTRAINT_KIND_CHOICES); // `writes_table: true` on the table ident (via `TABLE_NAME_ // EXISTING`) narrows the `.` slot's completion // candidates to that table's columns. -const CONSTRAINT_TARGET_NODES: &[Node] = - &[TABLE_NAME_EXISTING, Node::Punct('.'), COLUMN_NAME]; +const CONSTRAINT_TARGET_NODES: &[Node] = &[TABLE_NAME_EXISTING, Node::Punct('.'), COLUMN_NAME]; const CONSTRAINT_TARGET: Node = Node::Seq(CONSTRAINT_TARGET_NODES); const ADD_CONSTRAINT_NODES: &[Node] = &[ @@ -1145,9 +1142,9 @@ const COL_SPEC_NODES: &[Node] = &[ writes_table: false, writes_column: false, writes_user_listed_column: false, - writes_table_alias: false, - writes_cte_name: false, - writes_projection_alias: false, + writes_table_alias: false, + writes_cte_name: false, + writes_projection_alias: false, }, Node::Punct(')'), COLUMN_CONSTRAINT_SUFFIX, @@ -1275,10 +1272,14 @@ fn build_create_table(path: &MatchedPath, _source: &str) -> Result { + MatchedKind::Ident { + role: "col_name", .. + } => { pending_name = Some(item.text.clone()); } - MatchedKind::Ident { role: "col_type", .. } => { + MatchedKind::Ident { + role: "col_type", .. + } => { let ty = item.text.parse::().map_err(|_| ValidationError { message_key: "parse.error_wrapper", args: vec![("detail", "unknown type".to_string())], @@ -1380,7 +1381,8 @@ pub static CREATE: CommandNode = CommandNode { ast_builder: build_create_table, help_id: Some("ddl.create"), hint_ids: &["create_table"], - usage_ids: &["parse.usage.create_table"],}; + usage_ids: &["parse.usage.create_table"], +}; // ================================================================= // create_m2n — `create m:n relationship from to [as ]` @@ -1506,11 +1508,15 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result { + MatchedKind::Ident { + role: "col_name", .. + } => { pending_name = Some(item.text.clone()); } // Single-word type — resolve through the SQL alias map. - MatchedKind::Ident { role: "col_type", .. } => { + MatchedKind::Ident { + role: "col_type", .. + } => { let ty = Type::from_sql_name(&item.text).ok_or_else(|| ValidationError { message_key: "parse.error_wrapper", args: vec![("detail", "unknown type".to_string())], @@ -1533,7 +1539,9 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result { + MatchedKind::Ident { + role: "pk_column", .. + } => { primary_key.push(item.text.clone()); } // `not null` column constraint (only once a column exists; @@ -1557,7 +1565,10 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result = Vec::new(); while let Some(it) = items.peek() { match &it.kind { - MatchedKind::Ident { role: "unique_column", .. } => { + MatchedKind::Ident { + role: "unique_column", + .. + } => { cols.push(it.text.clone()); items.next(); } @@ -1575,7 +1586,10 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result c.unique = true, None if !cols.is_empty() => unique_constraints.push(cols), None => {} @@ -1588,16 +1602,17 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result { - if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("key"))) { + if matches!( + items.peek().map(|i| &i.kind), + Some(MatchedKind::Word("key")) + ) { items.next(); // Table-level `PRIMARY KEY (…)` is followed by `(` // (then `pk_column` idents, collected above); // column-level `PRIMARY KEY` is not, and marks the // most-recent column. - let table_level = matches!( - items.peek().map(|i| &i.kind), - Some(MatchedKind::Punct('(')) - ); + let table_level = + matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct('('))); if !table_level && let Some(last) = columns.last() { primary_key.push(last.name.clone()); } @@ -1647,12 +1662,20 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result] foreign key () // references [()] [on …]` (ADR-0035 §5, 4b). MatchedKind::Word("foreign") => { - if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("key"))) { + if matches!( + items.peek().map(|i| &i.kind), + Some(MatchedKind::Word("key")) + ) { items.next(); // `key` } // `( [, ]* )` — a compound @@ -1674,7 +1697,10 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result …` - if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("references"))) { + if matches!( + items.peek().map(|i| &i.kind), + Some(MatchedKind::Word("references")) + ) { items.next(); } let fk = @@ -1859,13 +1885,19 @@ where Some(MatchedKind::Word("cascade")) => ReferentialAction::Cascade, Some(MatchedKind::Word("restrict")) => ReferentialAction::Restrict, Some(MatchedKind::Word("set")) => { - if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("null"))) { + if matches!( + items.peek().map(|i| &i.kind), + Some(MatchedKind::Word("null")) + ) { items.next(); } ReferentialAction::SetNull } Some(MatchedKind::Word("no")) => { - if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("action"))) { + if matches!( + items.peek().map(|i| &i.kind), + Some(MatchedKind::Word("action")) + ) { items.next(); } ReferentialAction::NoAction @@ -1933,11 +1965,12 @@ pub static SQL_DROP_INDEX: CommandNode = CommandNode { // concrete keyword (`unique index` | `index`) — the trap-safe form (the // §3 rule forbids a leading *Optional*, not a leading `Choice`). The // builder reads `unique` presence via `contains_word("unique")`. -static SQL_CI_UNIQUE_INDEX_NODES: &[Node] = - &[Node::Word(Word::keyword("unique")), Node::Word(Word::keyword("index"))]; +static SQL_CI_UNIQUE_INDEX_NODES: &[Node] = &[ + Node::Word(Word::keyword("unique")), + Node::Word(Word::keyword("index")), +]; const SQL_CI_UNIQUE_INDEX: Node = Node::Seq(SQL_CI_UNIQUE_INDEX_NODES); -static SQL_CI_LEAD_CHOICES: &[Node] = - &[SQL_CI_UNIQUE_INDEX, Node::Word(Word::keyword("index"))]; +static SQL_CI_LEAD_CHOICES: &[Node] = &[SQL_CI_UNIQUE_INDEX, Node::Word(Word::keyword("index"))]; const SQL_CI_LEAD: Node = Node::Choice(SQL_CI_LEAD_CHOICES); static SQL_CI_IF_NOT_EXISTS_NODES: &[Node] = &[ @@ -2104,8 +2137,7 @@ static AT_RENAME_COLUMN_TAIL_NODES: &[Node] = &[ NEW_COLUMN_NAME, ]; const AT_RENAME_COLUMN_TAIL: Node = Node::Seq(AT_RENAME_COLUMN_TAIL_NODES); -static AT_RENAME_TABLE_TAIL_NODES: &[Node] = - &[Node::Word(Word::keyword("to")), NEW_TABLE_NAME]; +static AT_RENAME_TABLE_TAIL_NODES: &[Node] = &[Node::Word(Word::keyword("to")), NEW_TABLE_NAME]; const AT_RENAME_TABLE_TAIL: Node = Node::Seq(AT_RENAME_TABLE_TAIL_NODES); static AT_RENAME_TAIL_CHOICES: &[Node] = &[AT_RENAME_COLUMN_TAIL, AT_RENAME_TABLE_TAIL]; const AT_RENAME_TAIL: Node = Node::Choice(AT_RENAME_TAIL_CHOICES); @@ -2132,8 +2164,10 @@ static AT_AC_TYPE_NODES: &[Node] = &[ super::sql_create_table::SQL_TYPE, ]; const AT_AC_TYPE: Node = Node::Seq(AT_AC_TYPE_NODES); -static AT_AC_NOT_NULL_NODES: &[Node] = - &[Node::Word(Word::keyword("not")), Node::Word(Word::keyword("null"))]; +static AT_AC_NOT_NULL_NODES: &[Node] = &[ + Node::Word(Word::keyword("not")), + Node::Word(Word::keyword("null")), +]; const AT_AC_NOT_NULL: Node = Node::Seq(AT_AC_NOT_NULL_NODES); static AT_AC_SET_DATA_TYPE_NODES: &[Node] = &[ Node::Word(Word::keyword("data")), @@ -2149,8 +2183,7 @@ static AT_AC_SET_TAIL_CHOICES: &[Node] = &[ const AT_AC_SET_TAIL: Node = Node::Choice(AT_AC_SET_TAIL_CHOICES); static AT_AC_SET_NODES: &[Node] = &[Node::Word(Word::keyword("set")), AT_AC_SET_TAIL]; const AT_AC_SET: Node = Node::Seq(AT_AC_SET_NODES); -static AT_AC_DROP_TAIL_CHOICES: &[Node] = - &[AT_AC_NOT_NULL, Node::Word(Word::keyword("default"))]; +static AT_AC_DROP_TAIL_CHOICES: &[Node] = &[AT_AC_NOT_NULL, Node::Word(Word::keyword("default"))]; const AT_AC_DROP_TAIL: Node = Node::Choice(AT_AC_DROP_TAIL_CHOICES); static AT_AC_DROP_NODES: &[Node] = &[Node::Word(Word::keyword("drop")), AT_AC_DROP_TAIL]; const AT_AC_DROP: Node = Node::Seq(AT_AC_DROP_NODES); @@ -2258,10 +2291,14 @@ fn build_alter_add_column_spec( let mut items = path.items.iter().peekable(); while let Some(item) = items.next() { match &item.kind { - MatchedKind::Ident { role: "col_name", .. } => { + MatchedKind::Ident { + role: "col_name", .. + } => { pending_name = Some(item.text.clone()); } - MatchedKind::Ident { role: "col_type", .. } => { + MatchedKind::Ident { + role: "col_type", .. + } => { let ty = Type::from_sql_name(&item.text).ok_or_else(|| ValidationError { message_key: "parse.error_wrapper", args: vec![("detail", "unknown type".to_string())], @@ -2280,7 +2317,10 @@ fn build_alter_add_column_spec( spec = Some(ColumnSpec::new(name, Type::Real)); } MatchedKind::Word("not") => { - if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("null"))) { + if matches!( + items.peek().map(|i| &i.kind), + Some(MatchedKind::Word("null")) + ) { items.next(); if let Some(s) = spec.as_mut() { s.not_null = true; @@ -2326,11 +2366,15 @@ fn build_alter_column_type(path: &MatchedPath) -> Result { - ty = Some(Type::from_sql_name(&item.text).ok_or_else(|| ValidationError { - message_key: "parse.error_wrapper", - args: vec![("detail", "unknown type".to_string())], - })?); + MatchedKind::Ident { + role: "col_type", .. + } => { + ty = Some( + Type::from_sql_name(&item.text).ok_or_else(|| ValidationError { + message_key: "parse.error_wrapper", + args: vec![("detail", "unknown type".to_string())], + })?, + ); } MatchedKind::Word("double") => { if matches!( @@ -2379,7 +2423,10 @@ fn build_alter_column_attr( message_key: "parse.error_wrapper", args: vec![("detail", "set default needs a value".to_string())], })?; - AlterTableAction::SetColumnDefault { column, default_sql } + AlterTableAction::SetColumnDefault { + column, + default_sql, + } } (false, true) => AlterTableAction::DropColumnDefault { column }, (true, false) => AlterTableAction::SetColumnNotNull { column }, @@ -2495,10 +2542,7 @@ fn build_alter_add_table_constraint( /// Capture the raw SQL text of an `ADD … CHECK ()` (ADR-0035 §4g). /// `sql_expr` is validate-only, so the expression is captured by byte /// span — the 4a.2 / 4e mechanism. -fn capture_table_check_sql( - path: &MatchedPath, - source: &str, -) -> Result { +fn capture_table_check_sql(path: &MatchedPath, source: &str) -> Result { let mut items = path.items.iter().peekable(); while let Some(item) = items.next() { if matches!(item.kind, MatchedKind::Word("check")) @@ -2528,7 +2572,10 @@ fn build_alter_fk(path: &MatchedPath) -> SqlForeignKey { items.next(); } items.next(); // `foreign` - if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("key"))) { + if matches!( + items.peek().map(|i| &i.kind), + Some(MatchedKind::Word("key")) + ) { items.next(); } if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct('('))) { @@ -2548,7 +2595,10 @@ fn build_alter_fk(path: &MatchedPath) -> SqlForeignKey { if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct(')'))) { items.next(); } - if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("references"))) { + if matches!( + items.peek().map(|i| &i.kind), + Some(MatchedKind::Word("references")) + ) { items.next(); } // `ALTER TABLE … ADD FOREIGN KEY (…)` is the table-level form. @@ -2626,7 +2676,10 @@ mod constraint_tests { fn an_unconstrained_create_table_still_parses() { let cols = create_columns("create table T with pk id(serial), name(text)"); assert_eq!(cols.len(), 2); - assert!(cols.iter().all(|c| !c.not_null && !c.unique && c.default.is_none())); + assert!( + cols.iter() + .all(|c| !c.not_null && !c.unique && c.default.is_none()) + ); } #[test] @@ -2651,7 +2704,9 @@ mod constraint_tests { #[test] fn add_column_parses_a_unique_constraint() { match parse_command("add column to T: email (text) unique").expect("parse") { - Command::AddColumn { unique, not_null, .. } => { + Command::AddColumn { + unique, not_null, .. + } => { assert!(unique); assert!(!not_null); } @@ -2682,9 +2737,7 @@ mod constraint_tests { fn check_with_a_parenthesised_sub_expression_parses() { // The check's own parens plus a nested group — the // builder's paren-depth scan must pair them correctly. - let cols = create_columns( - "create table T with pk n(int) check ((n > 0) or (n < -10))", - ); + let cols = create_columns("create table T with pk n(int) check ((n > 0) or (n < -10))"); assert!(cols[0].check.is_some()); } @@ -2731,8 +2784,7 @@ mod constraint_tests { #[test] fn add_constraint_check_parses() { - match parse_command("add constraint check (age >= 0) to Users.age").expect("parse") - { + match parse_command("add constraint check (age >= 0) to Users.age").expect("parse") { Command::AddConstraint { column, constraint, .. } => { @@ -2826,8 +2878,11 @@ mod sql_drop_table_tests { Command::DropColumn { .. } )); assert!(matches!( - parse_command_in_mode("drop relationship Customers_id_to_Orders_CustId", Mode::Advanced) - .expect("parses"), + parse_command_in_mode( + "drop relationship Customers_id_to_Orders_CustId", + Mode::Advanced + ) + .expect("parses"), Command::DropRelationship { .. } )); } @@ -2932,7 +2987,13 @@ mod sql_create_index_tests { columns, unique, if_not_exists, - } => Ci { name, table, columns, unique, if_not_exists }, + } => Ci { + name, + table, + columns, + unique, + if_not_exists, + }, other => panic!("expected SqlCreateIndex, got {other:?}"), } } @@ -3134,7 +3195,9 @@ mod sql_alter_table_tests { // The target slot carries the `reject_internal_table` validator // (mirroring CREATE TABLE), so an `__rdbms_*` target is refused // before submit — engine-neutral, not a raw engine error. - assert!(parse_command_in_mode("alter table T rename to __rdbms_evil", Mode::Advanced).is_err()); + assert!( + parse_command_in_mode("alter table T rename to __rdbms_evil", Mode::Advanced).is_err() + ); } #[test] @@ -3213,7 +3276,10 @@ mod sql_alter_table_tests { // alias map still applies through the synonym assert!(matches!( alter("alter table T alter column n set data type double precision").1, - AlterTableAction::AlterColumnType { ty: crate::dsl::types::Type::Real, .. } + AlterTableAction::AlterColumnType { + ty: crate::dsl::types::Type::Real, + .. + } )); } @@ -3238,7 +3304,10 @@ mod sql_alter_table_tests { #[test] fn alter_column_set_default_captures_raw_expr() { match alter("alter table T alter column qty set default 0").1 { - AlterTableAction::SetColumnDefault { column, default_sql } => { + AlterTableAction::SetColumnDefault { + column, + default_sql, + } => { assert_eq!(column, "qty"); assert_eq!(default_sql, "0"); } @@ -3317,7 +3386,9 @@ mod sql_alter_table_tests { match alter("alter table T add check (a < b)").1 { AlterTableAction::AddTableConstraint { name, constraint } => { assert_eq!(name, None); - assert!(matches!(*constraint, TableConstraint::Check { ref expr_sql } if expr_sql == "a < b")); + assert!( + matches!(*constraint, TableConstraint::Check { ref expr_sql } if expr_sql == "a < b") + ); } other => panic!("expected AddTableConstraint/Check, got {other:?}"), } @@ -3335,7 +3406,9 @@ mod sql_alter_table_tests { match alter("alter table T add unique (a, b)").1 { AlterTableAction::AddTableConstraint { name, constraint } => { assert_eq!(name, None); - assert!(matches!(*constraint, TableConstraint::Unique { ref columns } if columns == &["a".to_string(), "b".to_string()])); + assert!( + matches!(*constraint, TableConstraint::Unique { ref columns } if columns == &["a".to_string(), "b".to_string()]) + ); } other => panic!("expected AddTableConstraint/Unique, got {other:?}"), } @@ -3352,7 +3425,9 @@ mod sql_alter_table_tests { ) .expect_err("a named UNIQUE constraint is refused"); assert!( - err.to_string().to_lowercase().contains("unique constraint cannot be named"), + err.to_string() + .to_lowercase() + .contains("unique constraint cannot be named"), "expected the builder's named-UNIQUE refusal, got: {err}" ); } @@ -3364,7 +3439,9 @@ mod sql_alter_table_tests { let err = parse_command_in_mode("alter table T add primary key (id)", Mode::Advanced) .expect_err("ADD PRIMARY KEY is refused"); assert!( - err.to_string().to_lowercase().contains("primary key is fixed at creation"), + err.to_string() + .to_lowercase() + .contains("primary key is fixed at creation"), "expected the builder's ADD-PRIMARY-KEY refusal, got: {err}" ); } @@ -3392,7 +3469,10 @@ mod sql_alter_table_tests { assert_eq!(name.as_deref(), Some("fk_p")); match *constraint { TableConstraint::ForeignKey(fk) => { - assert_eq!(fk.parent_columns, None, "bare reference resolves at execution"); + assert_eq!( + fk.parent_columns, None, + "bare reference resolves at execution" + ); } other => panic!("expected ForeignKey, got {other:?}"), } diff --git a/src/dsl/grammar/expr.rs b/src/dsl/grammar/expr.rs index 8b7c324..4b7113e 100644 --- a/src/dsl/grammar/expr.rs +++ b/src/dsl/grammar/expr.rs @@ -79,9 +79,9 @@ const EXPR_COLUMN: Node = Node::Ident { writes_table: false, writes_column: true, writes_user_listed_column: false, -writes_table_alias: false, -writes_cte_name: false, -writes_projection_alias: false, + writes_table_alias: false, + writes_cte_name: false, + writes_projection_alias: false, }; /// Operand alternatives. The literal keywords (`null` / `true` @@ -126,8 +126,7 @@ fn where_rhs_operand(ctx: &WalkContext) -> Node { // the leak is per distinct column (the walker // memoizes `DynamicSubgrammar` resolution on // `current_column`), not per keystroke. - let leaked: &'static str = - Box::leak(col.name.clone().into_boxed_str()); + let leaked: &'static str = Box::leak(col.name.clone().into_boxed_str()); Node::TypedValueSlot { ty: col.user_type, column_name: Some(leaked), @@ -260,10 +259,8 @@ static PAREN_GROUP_NODES: &[Node] = &[ Node::Subgrammar(&OR_EXPR), Node::Punct(')'), ]; -static BOOL_PRIMARY_CHOICES: &[Node] = &[ - Node::Seq(PAREN_GROUP_NODES), - Node::Subgrammar(&PREDICATE), -]; +static BOOL_PRIMARY_CHOICES: &[Node] = + &[Node::Seq(PAREN_GROUP_NODES), Node::Subgrammar(&PREDICATE)]; static BOOL_PRIMARY: Node = Node::Choice(BOOL_PRIMARY_CHOICES); /// `not_expr := NOT not_expr | bool_primary`. @@ -271,10 +268,7 @@ static NOT_FORM_NODES: &[Node] = &[ Node::Word(Word::keyword("not")), Node::Subgrammar(&NOT_EXPR), ]; -static NOT_EXPR_CHOICES: &[Node] = &[ - Node::Seq(NOT_FORM_NODES), - Node::Subgrammar(&BOOL_PRIMARY), -]; +static NOT_EXPR_CHOICES: &[Node] = &[Node::Seq(NOT_FORM_NODES), Node::Subgrammar(&BOOL_PRIMARY)]; static NOT_EXPR: Node = Node::Choice(NOT_EXPR_CHOICES); /// `and_expr := not_expr ( AND not_expr )*`. @@ -296,10 +290,7 @@ static AND_EXPR: Node = Node::Seq(AND_EXPR_NODES); /// `or_expr := and_expr ( OR and_expr )*` — the fragment entry /// point. `update` / `delete` / `show data` reference this /// through `Node::Subgrammar(&OR_EXPR)`. -static OR_TAIL_NODES: &[Node] = &[ - Node::Word(Word::keyword("or")), - Node::Subgrammar(&AND_EXPR), -]; +static OR_TAIL_NODES: &[Node] = &[Node::Word(Word::keyword("or")), Node::Subgrammar(&AND_EXPR)]; static OR_TAIL: Node = Node::Seq(OR_TAIL_NODES); static OR_EXPR_NODES: &[Node] = &[ Node::Subgrammar(&AND_EXPR), @@ -534,18 +525,18 @@ impl<'a> ExprParser<'a> { let span = item.span; let literal = |value: Value| Operand::Literal { value, span }; match &item.kind { - MatchedKind::Ident { role: "expr_column", .. } => { - Ok(Operand::Column { name: item.text.clone(), span }) - } + MatchedKind::Ident { + role: "expr_column", + .. + } => Ok(Operand::Column { + name: item.text.clone(), + span, + }), MatchedKind::Word("null") => Ok(literal(Value::Null)), MatchedKind::Word("true") => Ok(literal(Value::Bool(true))), MatchedKind::Word("false") => Ok(literal(Value::Bool(false))), - MatchedKind::NumberLit => { - Ok(literal(Value::Number(item.text.clone()))) - } - MatchedKind::StringLit => { - Ok(literal(Value::Text(item.text.clone()))) - } + MatchedKind::NumberLit => Ok(literal(Value::Number(item.text.clone()))), + MatchedKind::StringLit => Ok(literal(Value::Text(item.text.clone()))), _ => Err(drift_error("expected a column or literal operand")), } } @@ -591,8 +582,7 @@ mod tests { let mut ctx = WalkContext::new(); let mut path = MatchedPath::new(); let mut per_byte = Vec::new(); - let result = - walk_node(input, 0, &OR_EXPR, &mut ctx, &mut path, &mut per_byte); + let result = walk_node(input, 0, &OR_EXPR, &mut ctx, &mut path, &mut per_byte); match result { NodeWalkResult::Matched { end, .. } => { assert!( @@ -730,8 +720,7 @@ mod tests { negated: false, }), ); - let Expr::Predicate(Predicate::Like { negated, .. }) = - parse_expr("Name not like 'A%'") + let Expr::Predicate(Predicate::Like { negated, .. }) = parse_expr("Name not like 'A%'") else { panic!("expected a negated Like"); }; @@ -794,16 +783,16 @@ mod tests { fn nested_parentheses_round_trip() { // Exercises the Subgrammar recursion a few levels deep. let expr = parse_expr("((a = 1 and b = 2) or (c = 3))"); - assert!(matches!(expr, Expr::Or(_) | Expr::And(_) | Expr::Predicate(_))); + assert!(matches!( + expr, + Expr::Or(_) | Expr::And(_) | Expr::Predicate(_) + )); } #[test] fn case_insensitive_keywords() { // Keywords fold case; the built tree is identical. - assert_eq!( - parse_expr("a = 1 AND b = 2"), - parse_expr("a = 1 and b = 2"), - ); + assert_eq!(parse_expr("a = 1 AND b = 2"), parse_expr("a = 1 and b = 2"),); assert_eq!( parse_expr("Email IS NOT NULL"), parse_expr("Email is not null"), diff --git a/src/dsl/grammar/mod.rs b/src/dsl/grammar/mod.rs index ff2bc23..6122e09 100644 --- a/src/dsl/grammar/mod.rs +++ b/src/dsl/grammar/mod.rs @@ -27,9 +27,9 @@ pub mod data; pub mod ddl; pub mod expr; pub mod shared; -pub mod sql_expr; pub mod sql_create_table; pub mod sql_delete; +pub mod sql_expr; pub mod sql_insert; pub mod sql_select; pub mod sql_update; @@ -328,9 +328,7 @@ pub enum Node { /// A number literal. The optional `validator` runs against /// the matched text (used by Phase D value slots to enforce /// per-type integer/decimal rules). - NumberLit { - validator: Option, - }, + NumberLit { validator: Option }, /// A literal byte sequence at this position — matches /// bytes verbatim (whitespace-skipped) with a lookahead so /// `1` doesn't half-match `12` and `n` doesn't half-match @@ -701,7 +699,11 @@ fn selected_nodes_for_input_in_mode( .filter(|(_, _, c)| *c == CommandCategory::Simple) .collect() }; - if selected.is_empty() { candidates } else { selected } + if selected.is_empty() { + candidates + } else { + selected + } } /// The single usage template most relevant to `source`, when @@ -724,10 +726,7 @@ pub fn usage_key_for_input(source: &str) -> Option<&'static str> { /// disambiguates the single most-relevant usage key from the /// mode-selected key set. #[must_use] -pub fn usage_key_for_input_in_mode( - source: &str, - mode: crate::mode::Mode, -) -> Option<&'static str> { +pub fn usage_key_for_input_in_mode(source: &str, mode: crate::mode::Mode) -> Option<&'static str> { let (_entry, keys) = usage_keys_for_input_in_mode(source, mode)?; pick_form_key(source, &keys) } @@ -755,7 +754,10 @@ fn pick_form_key<'a>(source: &str, keys: &[&'a str]) -> Option<&'a str> { } // The `create m:n relationship` form (ADR-0045) opens with `m:n` // — a letter, so the digit branch misses it; its key ends `…m2n`. - if source[after..].get(..3).is_some_and(|s| s.eq_ignore_ascii_case("m:n")) { + if source[after..] + .get(..3) + .is_some_and(|s| s.eq_ignore_ascii_case("m:n")) + { return keys.iter().copied().find(|k| k.ends_with("m2n")); } // Otherwise the form word is an identifier — `column`, `index`, @@ -770,8 +772,7 @@ fn pick_form_key<'a>(source: &str, keys: &[&'a str]) -> Option<&'a str> { /// which read the same data through the legacy `usage::REGISTRY`. #[must_use] pub fn entry_words_alphabetised() -> Vec<&'static str> { - let mut words: Vec<&'static str> = - REGISTRY.iter().map(|(c, _)| c.entry.primary).collect(); + let mut words: Vec<&'static str> = REGISTRY.iter().map(|(c, _)| c.entry.primary).collect(); words.sort_unstable(); words.dedup(); words @@ -905,9 +906,7 @@ pub fn command_for_entry_word(word: &str) -> Option<(usize, &'static CommandNode /// returns its `Simple` DSL node and `Advanced` SQL node. The /// dispatcher picks among them by the active input mode. #[must_use] -pub fn commands_for_entry_word( - word: &str, -) -> Vec<(usize, &'static CommandNode, CommandCategory)> { +pub fn commands_for_entry_word(word: &str) -> Vec<(usize, &'static CommandNode, CommandCategory)> { REGISTRY .iter() .enumerate() @@ -1010,7 +1009,10 @@ mod hint_key_tests { ]; for c in classes { let key = format!("hint.err.{c}.what"); - assert!(cat.get(&key).is_some(), "missing tier-3 error block `{key}`"); + assert!( + cat.get(&key).is_some(), + "missing tier-3 error block `{key}`" + ); } } @@ -1098,10 +1100,7 @@ mod usage_key_tests { let cases = [ ("add column to T: c (int)", "parse.usage.add_column"), ("add index on T (c)", "parse.usage.add_index"), - ( - "add constraint unique to T.c", - "parse.usage.add_constraint", - ), + ("add constraint unique to T.c", "parse.usage.add_constraint"), ( "drop constraint check from T.c", "parse.usage.drop_constraint", @@ -1118,10 +1117,7 @@ mod usage_key_tests { ("drop table T", "parse.usage.drop_table"), ("drop column from table T: c", "parse.usage.drop_column"), ("drop index i", "parse.usage.drop_index"), - ( - "drop relationship r", - "parse.usage.drop_relationship", - ), + ("drop relationship r", "parse.usage.drop_relationship"), ("show data T", "parse.usage.show_data"), ("show table T", "parse.usage.show_table"), // `create` is multi-form (table vs m:n, ADR-0045): each typed diff --git a/src/dsl/grammar/shared.rs b/src/dsl/grammar/shared.rs index c62c2dc..10fc41f 100644 --- a/src/dsl/grammar/shared.rs +++ b/src/dsl/grammar/shared.rs @@ -7,8 +7,8 @@ use crate::completion::TableColumn; use crate::dsl::grammar::{ - HighlightClass, HintMode, IdentSource, IdentValidator, Node, - NumberValidator, ValidationError, Word, + HighlightClass, HintMode, IdentSource, IdentValidator, Node, NumberValidator, ValidationError, + Word, }; use crate::dsl::types::Type; use crate::dsl::walker::context::WalkContext; @@ -32,10 +32,7 @@ pub fn validate_type_name(value: &str) -> Result<(), ValidationError> { .join(", "); Err(ValidationError { message_key: "parse.custom.unknown_type", - args: vec![ - ("found", value.to_string()), - ("expected", expected), - ], + args: vec![("found", value.to_string()), ("expected", expected)], }) } } @@ -51,12 +48,12 @@ pub const TYPE_SLOT: Node = Node::Ident { role: "type", validator: Some(TYPE_VALIDATOR), highlight_override: Some(HighlightClass::Type), - writes_table: false, - writes_column: false, - writes_user_listed_column: false, -writes_table_alias: false, -writes_cte_name: false, -writes_projection_alias: false, + writes_table: false, + writes_column: false, + writes_user_listed_column: false, + writes_table_alias: false, + writes_cte_name: false, + writes_projection_alias: false, }; // --- Qualified column reference (`
.`) -------------- @@ -70,9 +67,9 @@ const QUALIFIED_COLUMN_NODES: &[Node] = &[ writes_table: false, writes_column: false, writes_user_listed_column: false, - writes_table_alias: false, - writes_cte_name: false, - writes_projection_alias: false, + writes_table_alias: false, + writes_cte_name: false, + writes_projection_alias: false, }, Node::Punct('.'), Node::Ident { @@ -83,9 +80,9 @@ const QUALIFIED_COLUMN_NODES: &[Node] = &[ writes_table: false, writes_column: false, writes_user_listed_column: false, - writes_table_alias: false, - writes_cte_name: false, - writes_projection_alias: false, + writes_table_alias: false, + writes_cte_name: false, + writes_projection_alias: false, }, ]; pub const QUALIFIED_COLUMN: Node = Node::Seq(QUALIFIED_COLUMN_NODES); @@ -313,9 +310,7 @@ const fn slot_inner_for_type(ty: Type) -> &'static Node { Type::Real => &REAL_SLOT_INNER, Type::Decimal => &DECIMAL_SLOT_INNER, Type::Bool => &BOOL_SLOT_INNER, - Type::Text | Type::Date | Type::DateTime | Type::Blob | Type::ShortId => { - &TEXT_SLOT_INNER - } + Type::Text | Type::Date | Type::DateTime | Type::Blob | Type::ShortId => &TEXT_SLOT_INNER, } } @@ -397,9 +392,7 @@ pub(crate) const FALLBACK_VALUE_LIST: Node = Node::Repeated { /// This is the single source of truth shared by [`column_value_list`] /// (which builds the typed slots) and the `data.rs` arity gate (which /// counts them) so the two never disagree (issue #17). -pub fn insert_target_columns<'c>( - ctx: &'c WalkContext<'_>, -) -> Option> { +pub fn insert_target_columns<'c>(ctx: &'c WalkContext<'_>) -> Option> { let table_cols = ctx.current_table_columns.as_ref()?; if table_cols.is_empty() { return None; diff --git a/src/dsl/grammar/sql_create_table.rs b/src/dsl/grammar/sql_create_table.rs index 49415c3..df6b7e5 100644 --- a/src/dsl/grammar/sql_create_table.rs +++ b/src/dsl/grammar/sql_create_table.rs @@ -405,8 +405,14 @@ const TABLE_FK_NAMED: Node = Node::Seq(TABLE_FK_NAMED_NODES); // / `foreign`) that disambiguates it from a column name. (A column // literally named with one of those keywords is therefore unavailable, // the same trade real SQL makes with its reserved words.) -static ELEMENT_CHOICES: &[Node] = - &[TABLE_PK, TABLE_UNIQUE, TABLE_CHECK, TABLE_FK_NAMED, TABLE_FK, COLUMN_DEF]; +static ELEMENT_CHOICES: &[Node] = &[ + TABLE_PK, + TABLE_UNIQUE, + TABLE_CHECK, + TABLE_FK_NAMED, + TABLE_FK, + COLUMN_DEF, +]; const ELEMENT_INNER: Node = Node::Choice(ELEMENT_CHOICES); // Issue #4: wrap the element slot in `IntroProse` so a fresh element // position (`create table T (` and after every `,`) surfaces a prose @@ -495,18 +501,31 @@ mod tests { let mut ctx = WalkContext::new(); let mut path = MatchedPath::new(); let mut per_byte = Vec::new(); - match walk_node(input, 0, &SQL_CREATE_TABLE_SHAPE, &mut ctx, &mut path, &mut per_byte) { + match walk_node( + input, + 0, + &SQL_CREATE_TABLE_SHAPE, + &mut ctx, + &mut path, + &mut per_byte, + ) { NodeWalkResult::Matched { end, .. } => input[end..].trim().is_empty(), _ => false, } } fn good(input: &str) { - assert!(walks(input), "{input:?} should be a valid CREATE TABLE tail"); + assert!( + walks(input), + "{input:?} should be a valid CREATE TABLE tail" + ); } fn bad(input: &str) { - assert!(!walks(input), "{input:?} should NOT walk as a complete CREATE TABLE tail"); + assert!( + !walks(input), + "{input:?} should NOT walk as a complete CREATE TABLE tail" + ); } #[test] @@ -638,7 +657,9 @@ mod tests { good("table t (id int, ref int references other(id))"); good("table t (id int, ref int references other)"); // bare ref good("table t (id int, ref int references other(id) on delete cascade)"); - good("table t (id int, ref int references other(id) on update set null on delete restrict)"); + good( + "table t (id int, ref int references other(id) on update set null on delete restrict)", + ); good("table t (id int, ref int, foreign key (ref) references other(id))"); good("table t (id int, ref int, constraint fk_x foreign key (ref) references other(id))"); good( @@ -691,7 +712,10 @@ mod builder_tests { assert_eq!(name, "t"); assert_eq!( cols, - vec![("id".to_string(), Type::Int), ("name".to_string(), Type::Text)] + vec![ + ("id".to_string(), Type::Int), + ("name".to_string(), Type::Text) + ] ); assert!(pk.is_empty(), "no PK declared"); assert!(!ine); @@ -740,7 +764,10 @@ mod builder_tests { let (_, cols, _, _) = sct("create table t (a varchar(255), b numeric(10, 2))"); assert_eq!( cols, - vec![("a".to_string(), Type::Text), ("b".to_string(), Type::Decimal)] + vec![ + ("a".to_string(), Type::Text), + ("b".to_string(), Type::Decimal) + ] ); } @@ -780,8 +807,7 @@ mod builder_tests { fn redundant_constraints_deduped_off_sole_pk_column() { // ADR-0035 §6.5: advanced mode accepts the redundant spelling // and silently drops the flags off the sole PK column. - match parse_command("create table t (id int primary key not null unique)") - .expect("parses") + match parse_command("create table t (id int primary key not null unique)").expect("parses") { Command::SqlCreateTable { columns, @@ -944,8 +970,7 @@ mod builder_tests { // depth 2, not an element boundary, so the following `check` // is still column-level. A naive "reset on any comma" would // misclassify it as table-level (the §4.2 probe). - let (cols, checks) = - parse_sct_checks("create table t (n numeric(10, 2) check (n > 0))"); + let (cols, checks) = parse_sct_checks("create table t (n numeric(10, 2) check (n > 0))"); assert_eq!(col(&cols, "n").check_sql.as_deref(), Some("n > 0")); assert!(checks.is_empty(), "no table-level CHECK was produced"); } @@ -977,8 +1002,7 @@ mod builder_tests { fn table_check_before_a_later_column_is_table_level() { // A CHECK element that appears between columns (not after a // column's type) is table-level even though more columns follow. - let (cols, checks) = - parse_sct_checks("create table t (a int, check (a > 0), b int)"); + let (cols, checks) = parse_sct_checks("create table t (a int, check (a > 0), b int)"); assert_eq!(checks, vec!["a > 0".to_string()]); assert!(col(&cols, "a").check_sql.is_none() && col(&cols, "b").check_sql.is_none()); } @@ -1004,7 +1028,10 @@ mod builder_tests { assert_eq!(fk.parent_columns, Some(vec!["id".to_string()])); assert_eq!(fk.on_delete, ReferentialAction::NoAction); assert_eq!(fk.on_update, ReferentialAction::NoAction); - assert!(fk.inline, "a column-level `references` is an inline FK (ADR-0043 D4)"); + assert!( + fk.inline, + "a column-level `references` is an inline FK (ADR-0043 D4)" + ); } #[test] @@ -1012,14 +1039,19 @@ mod builder_tests { // The table-level `FOREIGN KEY (...)` form is not inline, so it can // carry a multi-column reference and never triggers the inline // "use the table-level form" hint (ADR-0043 D4). - let fks = parse_sct_fks("create table t (id int, pid int, foreign key (pid) references parent(id))"); + let fks = parse_sct_fks( + "create table t (id int, pid int, foreign key (pid) references parent(id))", + ); assert!(!fks[0].inline, "table-level FOREIGN KEY is not inline"); } #[test] fn bare_inline_reference_has_no_parent_column() { let fks = parse_sct_fks("create table t (id int, pid int references parent)"); - assert_eq!(fks[0].parent_columns, None, "bare REFERENCES — resolved at execution"); + assert_eq!( + fks[0].parent_columns, None, + "bare REFERENCES — resolved at execution" + ); assert_eq!(fks[0].parent_table, "parent"); assert_eq!(fks[0].child_columns, vec!["pid".to_string()]); } @@ -1047,8 +1079,9 @@ mod builder_tests { #[test] fn table_level_foreign_key_captured() { - let fks = - parse_sct_fks("create table t (id int, pid int, foreign key (pid) references parent(id))"); + let fks = parse_sct_fks( + "create table t (id int, pid int, foreign key (pid) references parent(id))", + ); assert_eq!(fks.len(), 1); assert_eq!(fks[0].name, None); assert_eq!(fks[0].child_columns, vec!["pid".to_string()]); @@ -1073,8 +1106,20 @@ mod builder_tests { foreign key (a) references p(id), foreign key (b) references q(id))", ); assert_eq!(fks.len(), 2); - assert_eq!((fks[0].child_columns[0].as_str(), fks[0].parent_table.as_str()), ("a", "p")); - assert_eq!((fks[1].child_columns[0].as_str(), fks[1].parent_table.as_str()), ("b", "q")); + assert_eq!( + ( + fks[0].child_columns[0].as_str(), + fks[0].parent_table.as_str() + ), + ("a", "p") + ); + assert_eq!( + ( + fks[1].child_columns[0].as_str(), + fks[1].parent_table.as_str() + ), + ("b", "q") + ); } #[test] @@ -1108,7 +1153,12 @@ mod builder_tests { assert_eq!(foreign_keys[0].child_columns, vec!["pid".to_string()]); // the column-level CHECK still attaches to `pid` assert_eq!( - columns.iter().find(|c| c.name == "pid").unwrap().check_sql.as_deref(), + columns + .iter() + .find(|c| c.name == "pid") + .unwrap() + .check_sql + .as_deref(), Some("pid > 0") ); // the table-level CHECK is captured separately diff --git a/src/dsl/grammar/sql_delete.rs b/src/dsl/grammar/sql_delete.rs index b0ac701..f7b7dc1 100644 --- a/src/dsl/grammar/sql_delete.rs +++ b/src/dsl/grammar/sql_delete.rs @@ -82,7 +82,14 @@ mod tests { let mut ctx = WalkContext::new(); let mut path = MatchedPath::new(); let mut per_byte = Vec::new(); - match walk_node(input, 0, &SQL_DELETE_SHAPE, &mut ctx, &mut path, &mut per_byte) { + match walk_node( + input, + 0, + &SQL_DELETE_SHAPE, + &mut ctx, + &mut path, + &mut per_byte, + ) { NodeWalkResult::Matched { end, .. } => input[end..].trim().is_empty(), _ => false, } @@ -93,7 +100,10 @@ mod tests { } fn bad(input: &str) { - assert!(!walks(input), "{input:?} should NOT walk as a complete DELETE tail"); + assert!( + !walks(input), + "{input:?} should NOT walk as a complete DELETE tail" + ); } #[test] diff --git a/src/dsl/grammar/sql_expr.rs b/src/dsl/grammar/sql_expr.rs index 8b33eb7..51c27bd 100644 --- a/src/dsl/grammar/sql_expr.rs +++ b/src/dsl/grammar/sql_expr.rs @@ -82,19 +82,16 @@ const EXPR_IDENT: Node = Node::Ident { writes_table: false, writes_column: false, writes_user_listed_column: false, -writes_table_alias: false, -writes_cte_name: false, -writes_projection_alias: false, + writes_table_alias: false, + writes_cte_name: false, + writes_projection_alias: false, }; // ================================================================= // or_expr := and_expr ( OR and_expr )* — the fragment entry point // ================================================================= -static OR_TAIL_NODES: &[Node] = &[ - Node::Word(Word::keyword("or")), - Node::Subgrammar(&AND_EXPR), -]; +static OR_TAIL_NODES: &[Node] = &[Node::Word(Word::keyword("or")), Node::Subgrammar(&AND_EXPR)]; static OR_TAIL: Node = Node::Seq(OR_TAIL_NODES); static SQL_OR_EXPR_NODES: &[Node] = &[ Node::Subgrammar(&AND_EXPR), @@ -140,10 +137,7 @@ static NOT_FORM_NODES: &[Node] = &[ Node::Word(Word::keyword("not")), Node::Subgrammar(&NOT_EXPR), ]; -static NOT_EXPR_CHOICES: &[Node] = &[ - Node::Seq(NOT_FORM_NODES), - Node::Subgrammar(&PREDICATE), -]; +static NOT_EXPR_CHOICES: &[Node] = &[Node::Seq(NOT_FORM_NODES), Node::Subgrammar(&PREDICATE)]; static NOT_EXPR: Node = Node::Choice(NOT_EXPR_CHOICES); // ================================================================= @@ -156,10 +150,7 @@ static NOT_EXPR: Node = Node::Choice(NOT_EXPR_CHOICES); // needs. ADR-0026's DSL grammar made the tail mandatory because it // forbade a bare column as a boolean; SQL does not. -static PREDICATE_NODES: &[Node] = &[ - Node::Subgrammar(&ADDITIVE), - Node::Optional(&PREDICATE_TAIL), -]; +static PREDICATE_NODES: &[Node] = &[Node::Subgrammar(&ADDITIVE), Node::Optional(&PREDICATE_TAIL)]; static PREDICATE: Node = Node::Seq(PREDICATE_NODES); // ---- cmp_op := <= | <> | >= | != | < | > | = -------------------- @@ -181,10 +172,7 @@ static CMP_OP_CHOICES: &[Node] = &[ // ---- predicate_tail branches ------------------------------------ /// `cmp_op additive`. -static COMPARE_FORM_NODES: &[Node] = &[ - Node::Choice(CMP_OP_CHOICES), - Node::Subgrammar(&ADDITIVE), -]; +static COMPARE_FORM_NODES: &[Node] = &[Node::Choice(CMP_OP_CHOICES), Node::Subgrammar(&ADDITIVE)]; /// `IS [NOT] NULL`. static IS_NULL_NODES: &[Node] = &[ @@ -265,11 +253,7 @@ static PREDICATE_TAIL: Node = Node::Choice(PREDICATE_TAIL_CHOICES); // additive := multiplicative ( ( + | - | || ) multiplicative )* // ================================================================= -static ADD_OP_CHOICES: &[Node] = &[ - Node::Punct('+'), - Node::Punct('-'), - Node::Literal("||"), -]; +static ADD_OP_CHOICES: &[Node] = &[Node::Punct('+'), Node::Punct('-'), Node::Literal("||")]; static ADD_TAIL_NODES: &[Node] = &[ Node::Choice(ADD_OP_CHOICES), Node::Subgrammar(&MULTIPLICATIVE), @@ -289,15 +273,8 @@ static ADDITIVE: Node = Node::Seq(ADDITIVE_NODES); // multiplicative := unary ( ( * | / | % ) unary )* // ================================================================= -static MUL_OP_CHOICES: &[Node] = &[ - Node::Punct('*'), - Node::Punct('/'), - Node::Punct('%'), -]; -static MUL_TAIL_NODES: &[Node] = &[ - Node::Choice(MUL_OP_CHOICES), - Node::Subgrammar(&UNARY), -]; +static MUL_OP_CHOICES: &[Node] = &[Node::Punct('*'), Node::Punct('/'), Node::Punct('%')]; +static MUL_TAIL_NODES: &[Node] = &[Node::Choice(MUL_OP_CHOICES), Node::Subgrammar(&UNARY)]; static MUL_TAIL: Node = Node::Seq(MUL_TAIL_NODES); static MULTIPLICATIVE_NODES: &[Node] = &[ Node::Subgrammar(&UNARY), @@ -314,14 +291,8 @@ static MULTIPLICATIVE: Node = Node::Seq(MULTIPLICATIVE_NODES); // ================================================================= static SIGN_CHOICES: &[Node] = &[Node::Punct('-'), Node::Punct('+')]; -static UNARY_SIGN_NODES: &[Node] = &[ - Node::Choice(SIGN_CHOICES), - Node::Subgrammar(&UNARY), -]; -static UNARY_CHOICES: &[Node] = &[ - Node::Seq(UNARY_SIGN_NODES), - Node::Subgrammar(&PRIMARY), -]; +static UNARY_SIGN_NODES: &[Node] = &[Node::Choice(SIGN_CHOICES), Node::Subgrammar(&UNARY)]; +static UNARY_CHOICES: &[Node] = &[Node::Seq(UNARY_SIGN_NODES), Node::Subgrammar(&PRIMARY)]; static UNARY: Node = Node::Choice(UNARY_CHOICES); // ================================================================= @@ -402,10 +373,7 @@ static SIMPLE_CASE_NODES: &[Node] = &[ Node::Optional(&ELSE_CLAUSE), Node::Word(Word::keyword("end")), ]; -static CASE_BODY_CHOICES: &[Node] = &[ - Node::Seq(SEARCHED_CASE_NODES), - Node::Seq(SIMPLE_CASE_NODES), -]; +static CASE_BODY_CHOICES: &[Node] = &[Node::Seq(SEARCHED_CASE_NODES), Node::Seq(SIMPLE_CASE_NODES)]; static CASE_NODES: &[Node] = &[ Node::Word(Word::keyword("case")), Node::Choice(CASE_BODY_CHOICES), @@ -467,14 +435,11 @@ const QUALIFIED_REF_IDENT: Node = Node::Ident { writes_table: false, writes_column: false, writes_user_listed_column: false, -writes_table_alias: false, -writes_cte_name: false, -writes_projection_alias: false, + writes_table_alias: false, + writes_cte_name: false, + writes_projection_alias: false, }; -static QUALIFIED_REF_TAIL_NODES: &[Node] = &[ - Node::Punct('.'), - QUALIFIED_REF_IDENT, -]; +static QUALIFIED_REF_TAIL_NODES: &[Node] = &[Node::Punct('.'), QUALIFIED_REF_IDENT]; static NAME_OR_CALL_TAIL_CHOICES: &[Node] = &[ Node::Seq(QUALIFIED_REF_TAIL_NODES), @@ -531,7 +496,10 @@ mod tests { /// Assert `input` is *not* a complete SQL expression. fn bad(input: &str) { - assert!(!walks(input), "{input:?} should NOT walk as a complete expression"); + assert!( + !walks(input), + "{input:?} should NOT walk as a complete expression" + ); } #[test] @@ -643,13 +611,13 @@ mod tests { #[test] fn malformed_expressions_do_not_walk() { - bad("a +"); // dangling operator - bad("a in b"); // IN requires a parenthesised list - bad("= 1"); // no left operand - bad("a = "); // no right operand - bad("case a end"); // CASE with no WHEN clause - bad("and b"); // leading connective - bad("upper("); // unclosed call + bad("a +"); // dangling operator + bad("a in b"); // IN requires a parenthesised list + bad("= 1"); // no left operand + bad("a = "); // no right operand + bad("case a end"); // CASE with no WHEN clause + bad("and b"); // leading connective + bad("upper("); // unclosed call } #[test] @@ -680,9 +648,9 @@ mod tests { // The optional tail dispatches `.identifier` (qualified // ref) vs `(args)` (function call) by first token — a // bare ident remains a column ref. - good("foo(x)"); // function call - good("foo.bar"); // qualified ref - good("foo"); // bare ref + good("foo(x)"); // function call + good("foo.bar"); // qualified ref + good("foo"); // bare ref } #[test] diff --git a/src/dsl/grammar/sql_insert.rs b/src/dsl/grammar/sql_insert.rs index ab3411e..409781f 100644 --- a/src/dsl/grammar/sql_insert.rs +++ b/src/dsl/grammar/sql_insert.rs @@ -120,7 +120,10 @@ fn target_value_columns(ctx: &WalkContext) -> Vec { listed .iter() .filter_map(|name| { - table_cols.iter().find(|c| c.name.eq_ignore_ascii_case(name)).cloned() + table_cols + .iter() + .find(|c| c.name.eq_ignore_ascii_case(name)) + .cloned() }) .collect() }, @@ -148,7 +151,11 @@ fn target_value_columns(ctx: &WalkContext) -> Vec { fn tuple_value_list(ctx: &WalkContext, source: &str, pos: usize) -> Node { let cols = target_value_columns(ctx); let (count, closed) = count_tuple_values(source, pos); - let arity_ok = if closed { count == cols.len() } else { count <= cols.len() }; + let arity_ok = if closed { + count == cols.len() + } else { + count <= cols.len() + }; if !cols.is_empty() && arity_ok { Node::DynamicSubgrammar(sql_value_list) } else { @@ -304,8 +311,10 @@ static DO_UPDATE_NODES: &[Node] = &[ /// the enclosing Seq, each branch's FIRST token (`nothing` vs /// `update`) disambiguates, so a non-match of branch 0 is a clean /// `NoMatch` that falls through to branch 1. -static DO_ACTION_CHOICES: &[Node] = - &[Node::Word(Word::keyword("nothing")), Node::Seq(DO_UPDATE_NODES)]; +static DO_ACTION_CHOICES: &[Node] = &[ + Node::Word(Word::keyword("nothing")), + Node::Seq(DO_UPDATE_NODES), +]; // `const` — used by value in `ON_CONFLICT_CLAUSE_NODES`. const DO_ACTION: Node = Node::Choice(DO_ACTION_CHOICES); @@ -361,7 +370,14 @@ mod tests { let mut ctx = WalkContext::new(); let mut path = MatchedPath::new(); let mut per_byte = Vec::new(); - match walk_node(input, 0, &SQL_INSERT_SHAPE, &mut ctx, &mut path, &mut per_byte) { + match walk_node( + input, + 0, + &SQL_INSERT_SHAPE, + &mut ctx, + &mut path, + &mut per_byte, + ) { NodeWalkResult::Matched { end, .. } => input[end..].trim().is_empty(), _ => false, } @@ -372,7 +388,10 @@ mod tests { } fn bad(input: &str) { - assert!(!walks(input), "{input:?} should NOT walk as a complete INSERT tail"); + assert!( + !walks(input), + "{input:?} should NOT walk as a complete INSERT tail" + ); } #[test] @@ -418,8 +437,12 @@ mod tests { // 3h: ON CONFLICT … DO NOTHING / DO UPDATE (ADR-0033 §9). good("into t (id, name) values (1, 'x') on conflict (id) do nothing"); good("into t (id, name) values (1, 'x') on conflict do nothing"); - good("into t (id, name) values (1, 'x') on conflict (id) do update set name = excluded.name"); - good("into t (id, name) values (1, 'x') on conflict (id) do update set name = 'y' where id > 0"); + good( + "into t (id, name) values (1, 'x') on conflict (id) do update set name = excluded.name", + ); + good( + "into t (id, name) values (1, 'x') on conflict (id) do update set name = 'y' where id > 0", + ); // Multi-column conflict target + multi-assignment DO UPDATE. good("into t (a, b) values (1, 2) on conflict (a, b) do update set b = excluded.b, a = 9"); // ON CONFLICT composes with RETURNING (order: row source, diff --git a/src/dsl/grammar/sql_select.rs b/src/dsl/grammar/sql_select.rs index a8d40b6..0d488a5 100644 --- a/src/dsl/grammar/sql_select.rs +++ b/src/dsl/grammar/sql_select.rs @@ -141,8 +141,15 @@ static EMPTY_NOMATCH: Node = Node::Choice(&[]); /// suffix keywords. `as` is not listed — the AS-form alias is a /// separate `Choice` branch that fires before the lookahead. const PROJECTION_FOLLOW_SET: &[&str] = &[ - "from", "where", "group", "order", "having", "limit", - "union", "intersect", "except", + "from", + "where", + "group", + "order", + "having", + "limit", + "union", + "intersect", + "except", // `returning` belongs to an enclosing DML statement // (`INSERT … SELECT … RETURNING …`, ADR-0033 §5), never to a // projection item's bare alias — so a no-FROM SELECT row source @@ -158,9 +165,21 @@ const PROJECTION_FOLLOW_SET: &[&str] = &[ /// only when `b` has no alias — `on` is not a base-table name a /// learner would type as an alias. const TABLE_SOURCE_FOLLOW_SET: &[&str] = &[ - "where", "group", "order", "having", "limit", - "union", "intersect", "except", - "inner", "left", "right", "full", "cross", "join", "on", + "where", + "group", + "order", + "having", + "limit", + "union", + "intersect", + "except", + "inner", + "left", + "right", + "full", + "cross", + "join", + "on", // `returning` belongs to an enclosing DML statement // (`INSERT … SELECT … FROM t RETURNING …`, ADR-0033 §5), so the // SELECT row source must not read it as table `t`'s bare alias. @@ -172,15 +191,9 @@ fn peek_next_ident_lower(source: &str, pos: usize) -> Option { consume_ident(source, p).map(|(s, e)| source[s..e].to_ascii_lowercase()) } -fn projection_bare_alias_factory( - _: &WalkContext, - source: &str, - pos: usize, -) -> Node { +fn projection_bare_alias_factory(_: &WalkContext, source: &str, pos: usize) -> Node { match peek_next_ident_lower(source, pos) { - Some(word) - if PROJECTION_FOLLOW_SET.iter().any(|k| *k == word) => - { + Some(word) if PROJECTION_FOLLOW_SET.iter().any(|k| *k == word) => { Node::Subgrammar(&EMPTY_NOMATCH) } Some(_) => PROJECTION_BARE_ALIAS_IDENT, @@ -188,15 +201,9 @@ fn projection_bare_alias_factory( } } -fn table_source_bare_alias_factory( - _: &WalkContext, - source: &str, - pos: usize, -) -> Node { +fn table_source_bare_alias_factory(_: &WalkContext, source: &str, pos: usize) -> Node { match peek_next_ident_lower(source, pos) { - Some(word) - if TABLE_SOURCE_FOLLOW_SET.iter().any(|k| *k == word) => - { + Some(word) if TABLE_SOURCE_FOLLOW_SET.iter().any(|k| *k == word) => { Node::Subgrammar(&EMPTY_NOMATCH) } Some(_) => TABLE_SOURCE_BARE_ALIAS_IDENT, @@ -237,14 +244,12 @@ const TABLE_SOURCE_BARE_ALIAS_IDENT: Node = Node::Ident { writes_column: false, writes_user_listed_column: false, writes_table_alias: true, -writes_cte_name: false, -writes_projection_alias: false, + writes_cte_name: false, + writes_projection_alias: false, }; -static PROJECTION_AS_ALIAS_NODES: &[Node] = &[ - Node::Word(Word::keyword("as")), - PROJECTION_BARE_ALIAS_IDENT, -]; +static PROJECTION_AS_ALIAS_NODES: &[Node] = + &[Node::Word(Word::keyword("as")), PROJECTION_BARE_ALIAS_IDENT]; static PROJECTION_AS_ALIAS: Node = Node::Seq(PROJECTION_AS_ALIAS_NODES); static TABLE_SOURCE_AS_ALIAS_NODES: &[Node] = &[ @@ -258,17 +263,14 @@ static PROJECTION_ALIAS_CHOICES: &[Node] = &[ Node::Lookahead(projection_bare_alias_factory), ]; static PROJECTION_ALIAS_CHOICE: Node = Node::Choice(PROJECTION_ALIAS_CHOICES); -static PROJECTION_ALIAS_OPTIONAL: Node = - Node::Optional(&PROJECTION_ALIAS_CHOICE); +static PROJECTION_ALIAS_OPTIONAL: Node = Node::Optional(&PROJECTION_ALIAS_CHOICE); static TABLE_SOURCE_ALIAS_CHOICES: &[Node] = &[ Node::Subgrammar(&TABLE_SOURCE_AS_ALIAS), Node::Lookahead(table_source_bare_alias_factory), ]; -static TABLE_SOURCE_ALIAS_CHOICE: Node = - Node::Choice(TABLE_SOURCE_ALIAS_CHOICES); -static TABLE_SOURCE_ALIAS_OPTIONAL: Node = - Node::Optional(&TABLE_SOURCE_ALIAS_CHOICE); +static TABLE_SOURCE_ALIAS_CHOICE: Node = Node::Choice(TABLE_SOURCE_ALIAS_CHOICES); +static TABLE_SOURCE_ALIAS_OPTIONAL: Node = Node::Optional(&TABLE_SOURCE_ALIAS_CHOICE); // ================================================================= // Projection item @@ -282,16 +284,13 @@ const QUALIFIED_STAR_QUALIFIER: Node = Node::Ident { writes_table: false, writes_column: false, writes_user_listed_column: false, -writes_table_alias: false, -writes_cte_name: false, -writes_projection_alias: false, + writes_table_alias: false, + writes_cte_name: false, + writes_projection_alias: false, }; -static QUALIFIED_STAR_NODES: &[Node] = &[ - QUALIFIED_STAR_QUALIFIER, - Node::Punct('.'), - Node::Punct('*'), -]; +static QUALIFIED_STAR_NODES: &[Node] = + &[QUALIFIED_STAR_QUALIFIER, Node::Punct('.'), Node::Punct('*')]; static QUALIFIED_STAR: Node = Node::Seq(QUALIFIED_STAR_NODES); static PROJECTION_EXPR_ITEM_NODES: &[Node] = &[ @@ -310,11 +309,7 @@ static PROJECTION_EXPR_ITEM: Node = Node::Seq(PROJECTION_EXPR_ITEM_NODES); /// ambiguity between `t.*` and `sql_expr` (which can match a /// bare `t`), since the walker's `Choice` doesn't backtrack on /// a committed match. -fn projection_item_factory( - _: &WalkContext, - source: &str, - pos: usize, -) -> Node { +fn projection_item_factory(_: &WalkContext, source: &str, pos: usize) -> Node { let p = skip_whitespace(source, pos); let bytes = source.as_bytes(); if bytes.get(p) == Some(&b'*') { @@ -363,8 +358,7 @@ static DISTINCT_OR_ALL_CHOICES: &[Node] = &[ Node::Word(Word::keyword("all")), ]; static DISTINCT_OR_ALL_CHOICE: Node = Node::Choice(DISTINCT_OR_ALL_CHOICES); -static DISTINCT_OR_ALL_OPTIONAL: Node = - Node::Optional(&DISTINCT_OR_ALL_CHOICE); +static DISTINCT_OR_ALL_OPTIONAL: Node = Node::Optional(&DISTINCT_OR_ALL_CHOICE); // ================================================================= // Table source (FROM / JOIN target) @@ -379,8 +373,8 @@ const TABLE_NAME_IDENT: Node = Node::Ident { writes_column: false, writes_user_listed_column: false, writes_table_alias: false, -writes_cte_name: false, -writes_projection_alias: false, + writes_cte_name: false, + writes_projection_alias: false, }; static TABLE_SOURCE_NODES: &[Node] = &[ @@ -395,8 +389,7 @@ static TABLE_SOURCE: Node = Node::Seq(TABLE_SOURCE_NODES); const JOIN_WORD: Node = Node::Word(Word::keyword("join")); const ON_WORD: Node = Node::Word(Word::keyword("on")); -static OUTER_OPTIONAL: Node = - Node::Optional(&Node::Word(Word::keyword("outer"))); +static OUTER_OPTIONAL: Node = Node::Optional(&Node::Word(Word::keyword("outer"))); // `INNER JOIN` and bare `JOIN` are split into two Choice // branches so each branch has a distinct leading keyword @@ -585,8 +578,7 @@ static SET_OP_CHOICES: &[Node] = &[ ]; static SET_OP: Node = Node::Choice(SET_OP_CHOICES); -static SET_OP_TAIL_NODES: &[Node] = - &[Node::Subgrammar(&SET_OP), Node::Subgrammar(&SELECT_CORE)]; +static SET_OP_TAIL_NODES: &[Node] = &[Node::Subgrammar(&SET_OP), Node::Subgrammar(&SELECT_CORE)]; static SET_OP_TAIL: Node = Node::Seq(SET_OP_TAIL_NODES); static PLAIN_COMPOUND_NODES: &[Node] = &[ @@ -619,8 +611,7 @@ static WITH_PREFIXED_COMPOUND_NODES: &[Node] = &[ Node::Subgrammar(&WITH_CLAUSE), Node::Subgrammar(&PLAIN_COMPOUND), ]; -static WITH_PREFIXED_COMPOUND: Node = - Node::Seq(WITH_PREFIXED_COMPOUND_NODES); +static WITH_PREFIXED_COMPOUND: Node = Node::Seq(WITH_PREFIXED_COMPOUND_NODES); static COMPOUND_CHOICES: &[Node] = &[ Node::Subgrammar(&WITH_PREFIXED_COMPOUND), @@ -659,9 +650,9 @@ const CTE_COLUMN_IDENT: Node = Node::Ident { writes_table: false, writes_column: false, writes_user_listed_column: false, -writes_table_alias: false, -writes_cte_name: false, -writes_projection_alias: false, + writes_table_alias: false, + writes_cte_name: false, + writes_projection_alias: false, }; static CTE_COLUMN_LIST_NODES: &[Node] = &[ @@ -674,18 +665,13 @@ static CTE_COLUMN_LIST_NODES: &[Node] = &[ RPAREN, ]; static CTE_COLUMN_LIST_SEQ: Node = Node::Seq(CTE_COLUMN_LIST_NODES); -static CTE_COLUMN_LIST_OPTIONAL: Node = - Node::Optional(&CTE_COLUMN_LIST_SEQ); +static CTE_COLUMN_LIST_OPTIONAL: Node = Node::Optional(&CTE_COLUMN_LIST_SEQ); // CTE body recursion pushes a fresh lexical scope frame (ADR- // 0032 §4 / §10.2). Subqueries in `sql_expr.rs` do the same; // the top-level statement's own COMPOUND embedding does not // (it shares the implicit bottom frame). -static CTE_BODY_NODES: &[Node] = &[ - LPAREN, - Node::ScopedSubgrammar(&SQL_SELECT_COMPOUND), - RPAREN, -]; +static CTE_BODY_NODES: &[Node] = &[LPAREN, Node::ScopedSubgrammar(&SQL_SELECT_COMPOUND), RPAREN]; static CTE_BODY: Node = Node::Seq(CTE_BODY_NODES); static CTE_DEF_NODES: &[Node] = &[ @@ -807,9 +793,7 @@ mod tests { let mut path = MatchedPath::new(); let mut per_byte = Vec::new(); match walk_node(input, 0, fragment, &mut ctx, &mut path, &mut per_byte) { - NodeWalkResult::Matched { end, .. } => { - input[end..].trim().is_empty() - } + NodeWalkResult::Matched { end, .. } => input[end..].trim().is_empty(), _ => false, } } @@ -819,10 +803,7 @@ mod tests { } fn good(input: &str) { - assert!( - walks(input), - "{input:?} should be a valid SELECT statement" - ); + assert!(walks(input), "{input:?} should be a valid SELECT statement"); } fn bad(input: &str) { @@ -1051,16 +1032,12 @@ mod tests { #[test] fn set_op_chain() { - good( - "select a from t union select b from u intersect select c from v", - ); + good("select a from t union select b from u intersect select c from v"); } #[test] fn set_op_with_outer_order_by_and_limit() { - good( - "select a from t union select b from u order by a limit 10", - ); + good("select a from t union select b from u order by a limit 10"); } // ----- ORDER BY / LIMIT / OFFSET ----- @@ -1126,16 +1103,12 @@ mod tests { #[test] fn recursive_cte() { - good( - "with recursive r as (select 1 union all select 2) select * from r", - ); + good("with recursive r as (select 1 union all select 2) select * from r"); } #[test] fn multiple_ctes() { - good( - "with a as (select 1), b as (select 2) select * from a union select * from b", - ); + good("with a as (select 1), b as (select 2) select * from a union select * from b"); } // ----- subquery shapes (recursion through SQL_SELECT_COMPOUND) ----- @@ -1147,9 +1120,7 @@ mod tests { #[test] fn nested_cte_body_with_union() { - good( - "with x as (select 1 union select 2) select * from x", - ); + good("with x as (select 1 union select 2) select * from x"); } // ----- case insensitivity / spacing ----- @@ -1363,9 +1334,7 @@ mod tests { #[test] fn in_subquery_in_where_clause() { good("select * from t where id in (select user_id from orders)"); - good( - "select * from customers where id not in (select customer_id from blocklist)", - ); + good("select * from customers where id not in (select customer_id from blocklist)"); } #[test] @@ -1378,9 +1347,7 @@ mod tests { #[test] fn nested_subqueries() { - good( - "select * from t where x in (select y from u where y in (select z from v))", - ); + good("select * from t where x in (select y from u where y in (select z from v))"); } #[test] @@ -1393,8 +1360,6 @@ mod tests { #[test] fn cte_body_references_qualified_columns() { - good( - "with x as (select t.name, t.age from t) select x.name from x", - ); + good("with x as (select t.name, t.age from t) select x.name from x"); } } diff --git a/src/dsl/grammar/sql_update.rs b/src/dsl/grammar/sql_update.rs index 0c5294c..0caf1b1 100644 --- a/src/dsl/grammar/sql_update.rs +++ b/src/dsl/grammar/sql_update.rs @@ -119,7 +119,14 @@ mod tests { let mut ctx = WalkContext::new(); let mut path = MatchedPath::new(); let mut per_byte = Vec::new(); - match walk_node(input, 0, &SQL_UPDATE_SHAPE, &mut ctx, &mut path, &mut per_byte) { + match walk_node( + input, + 0, + &SQL_UPDATE_SHAPE, + &mut ctx, + &mut path, + &mut per_byte, + ) { NodeWalkResult::Matched { end, .. } => input[end..].trim().is_empty(), _ => false, } @@ -130,7 +137,10 @@ mod tests { } fn bad(input: &str) { - assert!(!walks(input), "{input:?} should NOT walk as a complete UPDATE tail"); + assert!( + !walks(input), + "{input:?} should NOT walk as a complete UPDATE tail" + ); } #[test] diff --git a/src/dsl/mod.rs b/src/dsl/mod.rs index 0c6b795..437fa73 100644 --- a/src/dsl/mod.rs +++ b/src/dsl/mod.rs @@ -21,9 +21,9 @@ pub mod walker; pub use action::ReferentialAction; pub use command::{ - AlterTableAction, AppCommand, ChangeColumnMode, ColumnSpec, Command, CompareOp, CopyScope, Expr, - IndexSelector, MessagesValue, ModeValue, Operand, Predicate, RelationshipSelector, RowFilter, - ShowListKind, SqlForeignKey, + AlterTableAction, AppCommand, ChangeColumnMode, ColumnSpec, Command, CompareOp, CopyScope, + Expr, IndexSelector, MessagesValue, ModeValue, Operand, Predicate, RelationshipSelector, + RowFilter, ShowListKind, SqlForeignKey, }; pub use parser::{ParseError, parse_command}; pub use types::Type; diff --git a/src/dsl/parser.rs b/src/dsl/parser.rs index 42ad61d..497f851 100644 --- a/src/dsl/parser.rs +++ b/src/dsl/parser.rs @@ -55,10 +55,9 @@ pub enum ParseError { impl std::fmt::Display for ParseError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - Self::Invalid { message, .. } => f.write_str(&crate::t!( - "parse.error_wrapper", - detail = message, - )), + Self::Invalid { message, .. } => { + f.write_str(&crate::t!("parse.error_wrapper", detail = message,)) + } Self::Empty => f.write_str(&crate::t!("parse.empty")), } } @@ -125,10 +124,7 @@ pub fn parse_command_with_schema( /// Schemaless, mode-aware parse (ADR-0030 §2). In `Mode::Simple` /// the walker gates SQL-only commands and produces the /// "this is SQL" hint instead of executing them. -pub fn parse_command_in_mode( - input: &str, - mode: Mode, -) -> Result { +pub fn parse_command_in_mode(input: &str, mode: Mode) -> Result { parse_command_inner(input, None, mode) } @@ -185,10 +181,8 @@ fn unknown_command_error(source: &str) -> ParseError { .collect(); let joined = oxford_join(&entries); let start = skip_whitespace(source, 0); - let (position, found_word) = consume_ident(source, start).map_or_else( - || (start, None), - |(s, e)| (s, Some(&source[s..e])), - ); + let (position, found_word) = consume_ident(source, start) + .map_or_else(|| (start, None), |(s, e)| (s, Some(&source[s..e]))); let message = found_word.map_or_else( || format!("expected one of {joined}"), |w| format!("expected one of {joined}, found `{w}`"), @@ -1034,19 +1028,22 @@ mod tests { false, ); assert_eq!( - ok("add 1:n relationship from Customers.Id to Orders.CustId on delete cascade on update set null"), + ok( + "add 1:n relationship from Customers.Id to Orders.CustId on delete cascade on update set null" + ), expected ); assert_eq!( - ok("add 1:n relationship from Customers.Id to Orders.CustId on update set null on delete cascade"), + ok( + "add 1:n relationship from Customers.Id to Orders.CustId on update set null on delete cascade" + ), expected ); } #[test] fn add_relationship_repeated_clause_errors() { - let e = - err("add 1:n relationship from C.id to O.cid on delete cascade on delete restrict"); + let e = err("add 1:n relationship from C.id to O.cid on delete cascade on delete restrict"); match e { ParseError::Invalid { message, .. } => { assert!(message.contains("specified twice"), "{message}"); @@ -1073,7 +1070,9 @@ mod tests { #[test] fn add_relationship_with_name_actions_and_flag() { assert_eq!( - ok("add 1:n relationship as cust_orders from Customers.Id to Orders.CustId on delete cascade on update no action --create-fk"), + ok( + "add 1:n relationship as cust_orders from Customers.Id to Orders.CustId on delete cascade on update no action --create-fk" + ), rel( Some("cust_orders"), ("Customers", "Id"), @@ -1300,10 +1299,7 @@ mod tests { #[test] fn advanced_ambiguous_update_routes_to_sql() { assert!(matches!( - parse_command_in_mode( - "update Orders set total = 0 where id = 1", - Mode::Advanced, - ), + parse_command_in_mode("update Orders set total = 0 where id = 1", Mode::Advanced,), Ok(Command::SqlUpdate { .. }) )); } @@ -1399,10 +1395,7 @@ mod tests { // in advanced mode)" pointer is added at the hint layer // (input_render), not in the parsed command/error here. assert!(matches!( - parse_command_in_mode( - "delete from Orders where id = 1 returning *", - Mode::Simple, - ), + parse_command_in_mode("delete from Orders where id = 1 returning *", Mode::Simple,), Err(ParseError::Invalid { .. }) )); } diff --git a/src/dsl/shortid.rs b/src/dsl/shortid.rs index b591bd7..6126115 100644 --- a/src/dsl/shortid.rs +++ b/src/dsl/shortid.rs @@ -9,8 +9,7 @@ use rand::RngExt; /// Base58 alphabet — Bitcoin-style. 0 / O / I / l are excluded /// because they are easily confused in print. -const ALPHABET: &[u8; 58] = - b"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; +const ALPHABET: &[u8; 58] = b"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; const DEFAULT_LEN: usize = 10; diff --git a/src/dsl/sql_functions.rs b/src/dsl/sql_functions.rs index 7daa93d..e924c48 100644 --- a/src/dsl/sql_functions.rs +++ b/src/dsl/sql_functions.rs @@ -43,29 +43,9 @@ /// - **Broader scalars:** `date`, `datetime`, `hex`, `ifnull`, /// `instr`, `nullif`, `random`, `replace`, `strftime`, `typeof`. pub const KNOWN_SQL_FUNCTIONS: &[&str] = &[ - "abs", - "avg", - "coalesce", - "count", - "date", - "datetime", - "hex", - "ifnull", - "instr", - "length", - "lower", - "max", - "min", - "nullif", - "random", - "replace", - "round", - "strftime", - "substr", - "sum", - "trim", - "typeof", - "upper", + "abs", "avg", "coalesce", "count", "date", "datetime", "hex", "ifnull", "instr", "length", + "lower", "max", "min", "nullif", "random", "replace", "round", "strftime", "substr", "sum", + "trim", "typeof", "upper", ]; /// Whether `partial` is a case-insensitive prefix of at least one @@ -80,9 +60,7 @@ pub const KNOWN_SQL_FUNCTIONS: &[&str] = &[ #[must_use] pub fn is_known_function_prefix(partial: &str) -> bool { let lowered = partial.to_lowercase(); - KNOWN_SQL_FUNCTIONS - .iter() - .any(|f| f.starts_with(&lowered)) + KNOWN_SQL_FUNCTIONS.iter().any(|f| f.starts_with(&lowered)) } #[cfg(test)] diff --git a/src/dsl/types.rs b/src/dsl/types.rs index 9ed4ed2..3168af7 100644 --- a/src/dsl/types.rs +++ b/src/dsl/types.rs @@ -59,11 +59,7 @@ impl Type { #[must_use] pub const fn sqlite_strict_type(self) -> &'static str { match self { - Self::Text - | Self::ShortId - | Self::Decimal - | Self::Date - | Self::DateTime => "TEXT", + Self::Text | Self::ShortId | Self::Decimal | Self::Date | Self::DateTime => "TEXT", Self::Int | Self::Serial | Self::Bool => "INTEGER", Self::Real => "REAL", Self::Blob => "BLOB", @@ -107,10 +103,7 @@ impl Type { /// match against a numeric column (ADR-0027, Amendment 1). #[must_use] pub const fn is_numeric(self) -> bool { - matches!( - self, - Self::Int | Self::Real | Self::Decimal | Self::Serial - ) + matches!(self, Self::Int | Self::Real | Self::Decimal | Self::Serial) } /// The user-facing type that an FK column should use to diff --git a/src/dsl/value.rs b/src/dsl/value.rs index 83d356d..be0a9b7 100644 --- a/src/dsl/value.rs +++ b/src/dsl/value.rs @@ -129,13 +129,14 @@ impl Value { fn bind_int(&self, column: &str, ty: Type) -> Result { match self { - Self::Number(n) => n - .parse::() - .map(Bound::Integer) - .map_err(|_| ValueError::Format { - column: column.to_string(), - message: format!("`{n}` is not a valid {ty} (whole number expected)"), - }), + Self::Number(n) => { + n.parse::() + .map(Bound::Integer) + .map_err(|_| ValueError::Format { + column: column.to_string(), + message: format!("`{n}` is not a valid {ty} (whole number expected)"), + }) + } other => Err(ValueError::TypeMismatch { column: column.to_string(), expected_human: format!("a whole number for `{ty}`"), @@ -241,9 +242,7 @@ pub(crate) fn validate_date(s: &str) -> Result<(), String> { // Expect YYYY-MM-DD: 10 chars, two dashes at fixed positions. let bytes = s.as_bytes(); if bytes.len() != 10 || bytes[4] != b'-' || bytes[7] != b'-' { - return Err(format!( - "`{s}` is not a date in `YYYY-MM-DD` form" - )); + return Err(format!("`{s}` is not a date in `YYYY-MM-DD` form")); } let year = parse_digits(&s[0..4]).ok_or_else(|| format!("`{s}`: invalid year"))?; let month = parse_digits(&s[5..7]).ok_or_else(|| format!("`{s}`: invalid month"))?; @@ -272,7 +271,9 @@ pub(crate) fn validate_datetime(s: &str) -> Result<(), String> { validate_date(date_part)?; let bytes = s.as_bytes(); if bytes[10] != b'T' { - return Err(format!("`{s}`: missing `T` separator between date and time")); + return Err(format!( + "`{s}`: missing `T` separator between date and time" + )); } if bytes[13] != b':' || bytes[16] != b':' { return Err(format!("`{s}`: time portion must be `HH:MM:SS`")); @@ -326,8 +327,14 @@ mod tests { #[test] fn integer_for_int_column() { - assert_eq!(n("42").bind_for_column("c", Type::Int).unwrap(), Bound::Integer(42)); - assert_eq!(n("-7").bind_for_column("c", Type::Int).unwrap(), Bound::Integer(-7)); + assert_eq!( + n("42").bind_for_column("c", Type::Int).unwrap(), + Bound::Integer(42) + ); + assert_eq!( + n("-7").bind_for_column("c", Type::Int).unwrap(), + Bound::Integer(-7) + ); } #[test] @@ -355,7 +362,9 @@ mod tests { #[test] fn shortid_validation_runs_on_text_for_shortid_column() { - let err = t("toolong_xyz_more").bind_for_column("c", Type::ShortId).unwrap_err(); + let err = t("toolong_xyz_more") + .bind_for_column("c", Type::ShortId) + .unwrap_err(); assert!(matches!(err, ValueError::Format { .. })); // Well-formed shortid binds fine. @@ -367,8 +376,14 @@ mod tests { #[test] fn bool_for_bool_column_maps_to_zero_or_one() { - assert_eq!(Value::Bool(true).bind_for_column("c", Type::Bool).unwrap(), Bound::Integer(1)); - assert_eq!(Value::Bool(false).bind_for_column("c", Type::Bool).unwrap(), Bound::Integer(0)); + assert_eq!( + Value::Bool(true).bind_for_column("c", Type::Bool).unwrap(), + Bound::Integer(1) + ); + assert_eq!( + Value::Bool(false).bind_for_column("c", Type::Bool).unwrap(), + Bound::Integer(0) + ); } #[test] @@ -377,13 +392,17 @@ mod tests { t("2025-01-15").bind_for_column("c", Type::Date).unwrap(), Bound::Text("2025-01-15".to_string()) ); - let err = t("2025/01/15").bind_for_column("c", Type::Date).unwrap_err(); + let err = t("2025/01/15") + .bind_for_column("c", Type::Date) + .unwrap_err(); assert!(matches!(err, ValueError::Format { .. })); } #[test] fn date_range_check() { - let err = t("2025-13-01").bind_for_column("c", Type::Date).unwrap_err(); + let err = t("2025-13-01") + .bind_for_column("c", Type::Date) + .unwrap_err(); assert!(matches!(err, ValueError::Format { message, .. } if message.contains("month"))); } diff --git a/src/dsl/walker/driver.rs b/src/dsl/walker/driver.rs index 231bc36..d99f1f2 100644 --- a/src/dsl/walker/driver.rs +++ b/src/dsl/walker/driver.rs @@ -28,12 +28,10 @@ use crate::completion::TableColumn; use crate::dsl::grammar::{HighlightClass, Node, ValidationError}; use crate::dsl::walker::context::WalkContext; use crate::dsl::walker::lex_helpers::{ - consume_bare_path, consume_flag, consume_ident, consume_number_literal, - consume_string_literal, skip_whitespace, -}; -use crate::dsl::walker::outcome::{ - ByteClass, Expectation, MatchedItem, MatchedKind, MatchedPath, + consume_bare_path, consume_flag, consume_ident, consume_number_literal, consume_string_literal, + skip_whitespace, }; +use crate::dsl::walker::outcome::{ByteClass, Expectation, MatchedItem, MatchedKind, MatchedPath}; /// Maximum nesting of `Node::Subgrammar` frames (ADR-0026 §1). /// @@ -77,10 +75,7 @@ static DYNAMIC_CACHE: LazyLock>> = /// Resolve a `DynamicSubgrammar` factory to a `&'static Node`, /// reusing a previously-leaked Node when the factory's inputs /// match a cached entry. -fn resolve_dynamic( - factory: fn(&WalkContext) -> Node, - ctx: &WalkContext, -) -> &'static Node { +fn resolve_dynamic(factory: fn(&WalkContext) -> Node, ctx: &WalkContext) -> &'static Node { let key = DynamicKey { factory: factory as usize, current_table_columns: ctx.current_table_columns.clone(), @@ -123,10 +118,7 @@ pub enum NodeWalkResult { expected: Vec, }, /// Committed and hit a hard mismatch or validator failure. - Failed { - position: usize, - kind: FailureKind, - }, + Failed { position: usize, kind: FailureKind }, } const fn matched(end: usize) -> NodeWalkResult { @@ -218,9 +210,7 @@ fn walk_node_inner( kind: FailureKind::Mismatch { expected: vec![] }, } } - Node::Subgrammar(inner) => { - walk_subgrammar(source, pos, inner, ctx, path, per_byte) - } + Node::Subgrammar(inner) => walk_subgrammar(source, pos, inner, ctx, path, per_byte), Node::ScopedSubgrammar(inner) => { walk_scoped_subgrammar(source, pos, inner, ctx, path, per_byte) } @@ -247,8 +237,7 @@ fn walk_node_inner( // DynamicSubgrammar wrapper that delegates to the // memoized `column_value_list`), so the per-walk // leak is a few bytes, not a whole typed tree. - let resolved: &'static Node = - Box::leak(Box::new(factory(ctx, source, pos))); + let resolved: &'static Node = Box::leak(Box::new(factory(ctx, source, pos))); walk_node(source, pos, resolved, ctx, path, per_byte) } Node::SetColumn(col) => { @@ -262,7 +251,10 @@ fn walk_node_inner( let col: &crate::completion::TableColumn = col; ctx.current_column = Some(col.clone()); ctx.pending_value_column = Some(col.name.clone()); - NodeWalkResult::Matched { end: pos, skipped: Vec::new() } + NodeWalkResult::Matched { + end: pos, + skipped: Vec::new(), + } } Node::TypedValueSlot { ty, @@ -342,7 +334,10 @@ fn walk_word( // Amendment 4). Plain keywords leave it `None`. class: word.highlight_override.unwrap_or(HighlightClass::Keyword), }); - NodeWalkResult::Matched { end, skipped: Vec::new() } + NodeWalkResult::Matched { + end, + skipped: Vec::new(), + } } else { NodeWalkResult::NoMatch { position, @@ -477,9 +472,7 @@ fn walk_ident( // ScopedSubgrammar (which is structurally guaranteed to be // the CTE body — no intervening scoped subgrammar in CTE // syntax) runs the harvest at body-frame exit. - if writes_cte_name - && let Some(frame) = ctx.from_scope_stack.last_mut() - { + if writes_cte_name && let Some(frame) = ctx.from_scope_stack.last_mut() { frame .cte_bindings .push(crate::dsl::walker::context::CteBinding { @@ -487,13 +480,12 @@ fn walk_ident( columns: Vec::new(), }); let placeholder_index = frame.cte_bindings.len() - 1; - ctx.pending_cte_harvest = - Some(crate::dsl::walker::context::PendingCteHarvest { - placeholder_index, - col_list: Vec::new(), - cte_name: text.clone(), - cte_name_span: (start, end), - }); + ctx.pending_cte_harvest = Some(crate::dsl::walker::context::PendingCteHarvest { + placeholder_index, + col_list: Vec::new(), + cte_name: text.clone(), + cte_name_span: (start, end), + }); } // ADR-0032 §10.3: the optional `(c1, c2, …)` rename list // between the cte name and `AS`. Each `cte_column` ident @@ -507,9 +499,7 @@ fn walk_ident( } // ADR-0032 §10.4: projection-list alias accumulator for // ORDER BY completion candidates. - if writes_projection_alias - && let Some(frame) = ctx.from_scope_stack.last_mut() - { + if writes_projection_alias && let Some(frame) = ctx.from_scope_stack.last_mut() { frame.projection_aliases.push(text.clone()); } if writes_column && matches!(src, crate::dsl::grammar::IdentSource::Columns) { @@ -529,9 +519,7 @@ fn walk_ident( .map(|c| c.name.clone()) .or_else(|| Some(text.clone())); } - if writes_user_listed_column - && matches!(src, crate::dsl::grammar::IdentSource::Columns) - { + if writes_user_listed_column && matches!(src, crate::dsl::grammar::IdentSource::Columns) { // Form A: `insert into (col1, col2, …)`. Append the // matched column name to user_listed_columns so the // inner `values (…)` slot list mirrors the user's @@ -564,7 +552,10 @@ fn walk_ident( // (issue #8 / ADR-0022 Amendment 4). class: highlight_override.unwrap_or(HighlightClass::Identifier), }); - NodeWalkResult::Matched { end, skipped: Vec::new() } + NodeWalkResult::Matched { + end, + skipped: Vec::new(), + } } fn walk_string_lit( @@ -648,7 +639,10 @@ fn walk_literal( end, class, }); - NodeWalkResult::Matched { end, skipped: Vec::new() } + NodeWalkResult::Matched { + end, + skipped: Vec::new(), + } } fn walk_number_lit( @@ -683,7 +677,10 @@ fn walk_number_lit( end, class: HighlightClass::Number, }); - NodeWalkResult::Matched { end, skipped: Vec::new() } + NodeWalkResult::Matched { + end, + skipped: Vec::new(), + } } fn walk_flag( @@ -717,7 +714,10 @@ fn walk_flag( end, class: HighlightClass::Flag, }); - NodeWalkResult::Matched { end, skipped: Vec::new() } + NodeWalkResult::Matched { + end, + skipped: Vec::new(), + } } #[allow(clippy::too_many_arguments)] @@ -784,7 +784,10 @@ fn walk_repeated( count += 1; last_item_skipped = skipped; } - NodeWalkResult::NoMatch { expected, position: inner_pos } => { + NodeWalkResult::NoMatch { + expected, + position: inner_pos, + } => { // Mid-typing-the-next-item recovery: if the // separator just consumed and the inner failed // at EOF, the user is partway through typing the @@ -860,7 +863,10 @@ fn walk_bare_path( end, class: HighlightClass::String, }); - NodeWalkResult::Matched { end, skipped: Vec::new() } + NodeWalkResult::Matched { + end, + skipped: Vec::new(), + } } fn walk_choice( @@ -1031,7 +1037,10 @@ fn walk_optional( skipped: expected, } } - NodeWalkResult::Incomplete { position: p, expected } if !inner_committed => { + NodeWalkResult::Incomplete { + position: p, + expected, + } if !inner_committed => { // Inner reported Incomplete without consuming // anything — same as NoMatch from the user's // perspective. Roll back and skip. @@ -1156,9 +1165,7 @@ fn walk_scoped_subgrammar( // walks that NoMatch / Incomplete / Fail leave the placeholder // empty (the outer-frame state is also discarded in the // speculative path, so this is correct). - if let (Some(req), NodeWalkResult::Matched { end, .. }) = - (pending_cte, &result) - { + if let (Some(req), NodeWalkResult::Matched { end, .. }) = (pending_cte, &result) { run_cte_harvest(ctx, path, source, pos, *end, &req); } @@ -1240,9 +1247,8 @@ fn run_cte_harvest( select_idx = Some(i + 1); // start of projection list } MatchedKind::Word( - "from" | "where" | "group" | "having" | "order" - | "limit" | "offset" | "union" | "intersect" - | "except", + "from" | "where" | "group" | "having" | "order" | "limit" | "offset" | "union" + | "intersect" | "except", ) if select_idx.is_some() => { end_idx = i; break; @@ -1281,12 +1287,7 @@ fn run_cte_harvest( // Classify each projection item per ADR-0032 §10.3. let mut derived: Vec = Vec::new(); for slice in item_slices { - classify_projection_item( - slice, - body_frame, - &ctx.from_scope_stack, - &mut derived, - ); + classify_projection_item(slice, body_frame, &ctx.from_scope_stack, &mut derived); } // Apply (c1, c2, …) positional rename if provided. Types @@ -1339,8 +1340,7 @@ fn run_cte_harvest( let stack_len = ctx.from_scope_stack.len(); if stack_len >= 2 && let Some(outer) = ctx.from_scope_stack.get_mut(stack_len - 2) - && let Some(placeholder) = - outer.cte_bindings.get_mut(req.placeholder_index) + && let Some(placeholder) = outer.cte_bindings.get_mut(req.placeholder_index) { placeholder.columns = derived; } @@ -1368,9 +1368,7 @@ fn classify_projection_item( // empty because it wasn't a base-table lookup), resolve // through to the in-scope CteBinding so nested CTEs project // correctly. - if expr_slice.len() == 1 - && matches!(expr_slice[0].kind, MatchedKind::Punct('*')) - { + if expr_slice.len() == 1 && matches!(expr_slice[0].kind, MatchedKind::Punct('*')) { for binding in &body_frame.from_scope { for col in expand_binding(binding, scope_stack) { out.push(col); @@ -1383,7 +1381,10 @@ fn classify_projection_item( if expr_slice.len() == 3 && matches!( expr_slice[0].kind, - MatchedKind::Ident { role: "qualified_star_qualifier", .. } + MatchedKind::Ident { + role: "qualified_star_qualifier", + .. + } ) && matches!(expr_slice[1].kind, MatchedKind::Punct('.')) && matches!(expr_slice[2].kind, MatchedKind::Punct('*')) @@ -1413,11 +1414,7 @@ fn classify_projection_item( ) { let col_text = &expr_slice[0].text; - let resolved_type = resolve_bare_column_type_in_frame( - body_frame, - scope_stack, - col_text, - ); + let resolved_type = resolve_bare_column_type_in_frame(body_frame, scope_stack, col_text); let name = alias.unwrap_or_else(|| col_text.clone()); out.push(CteColumn { name: Some(name), @@ -1447,12 +1444,7 @@ fn classify_projection_item( { let qual = &expr_slice[0].text; let col_text = &expr_slice[2].text; - let resolved_type = resolve_qualified_column_type( - body_frame, - scope_stack, - qual, - col_text, - ); + let resolved_type = resolve_qualified_column_type(body_frame, scope_stack, qual, col_text); let name = alias.unwrap_or_else(|| col_text.clone()); out.push(CteColumn { name: Some(name), @@ -1493,16 +1485,8 @@ fn strip_trailing_alias<'a>( } ) { // Optional preceding `AS` keyword. - if slice.len() >= 2 - && matches!( - slice[slice.len() - 2].kind, - MatchedKind::Word("as") - ) - { - return ( - &slice[..slice.len() - 2], - Some(last.text.clone()), - ); + if slice.len() >= 2 && matches!(slice[slice.len() - 2].kind, MatchedKind::Word("as")) { + return (&slice[..slice.len() - 2], Some(last.text.clone())); } return (&slice[..slice.len() - 1], Some(last.text.clone())); } @@ -1613,8 +1597,8 @@ fn merge_expected(dst: &mut Vec, src: Vec) { #[cfg(test)] mod tests { use super::{ - DYNAMIC_CACHE, FailureKind, MAX_SUBGRAMMAR_DEPTH, NodeWalkResult, - resolve_dynamic, walk_node, + DYNAMIC_CACHE, FailureKind, MAX_SUBGRAMMAR_DEPTH, NodeWalkResult, resolve_dynamic, + walk_node, }; use crate::dsl::grammar::{Node, Word}; use crate::dsl::walker::context::WalkContext; @@ -1629,18 +1613,14 @@ mod tests { Node::Subgrammar(&NESTED), Node::Punct(')'), ]; - static NESTED_CHOICES: &[Node] = &[ - Node::Seq(NESTED_GROUP), - Node::Word(Word::keyword("x")), - ]; + static NESTED_CHOICES: &[Node] = &[Node::Seq(NESTED_GROUP), Node::Word(Word::keyword("x"))]; static NESTED: Node = Node::Choice(NESTED_CHOICES); fn walk_nested(input: &str) -> NodeWalkResult { let mut ctx = WalkContext::new(); let mut path = MatchedPath::new(); let mut per_byte = Vec::new(); - let result = - walk_node(input, 0, &NESTED, &mut ctx, &mut path, &mut per_byte); + let result = walk_node(input, 0, &NESTED, &mut ctx, &mut path, &mut per_byte); assert_eq!( ctx.subgrammar_depth, 0, "subgrammar_depth must be restored to 0 after the walk", @@ -1726,14 +1706,8 @@ mod tests { fn resolve_dynamic_cache_is_populated() { let ctx = WalkContext::new(); let _ = resolve_dynamic(const_factory, &ctx); - let populated = !DYNAMIC_CACHE - .lock() - .expect("cache lock") - .is_empty(); - assert!( - populated, - "resolve_dynamic should populate the memo cache", - ); + let populated = !DYNAMIC_CACHE.lock().expect("cache lock").is_empty(); + assert!(populated, "resolve_dynamic should populate the memo cache",); } // ---- ScopedSubgrammar (ADR-0032 §10.2) ----------------------- @@ -1758,14 +1732,7 @@ mod tests { let mut path = MatchedPath::new(); let mut per_byte = Vec::new(); let baseline_frames = ctx.from_scope_stack.len(); - let result = walk_node( - input, - 0, - &SCOPED_NESTED, - &mut ctx, - &mut path, - &mut per_byte, - ); + let result = walk_node(input, 0, &SCOPED_NESTED, &mut ctx, &mut path, &mut per_byte); assert_eq!( ctx.subgrammar_depth, 0, "subgrammar_depth must be restored to 0 after the walk", @@ -1801,9 +1768,9 @@ mod tests { kind: FailureKind::Validation(err), .. } => assert_eq!(err.message_key, "parse.custom.expression_too_deep"), - other => panic!( - "expected expression_too_deep on pathological scoped nesting, got {other:?}", - ), + other => { + panic!("expected expression_too_deep on pathological scoped nesting, got {other:?}",) + } } } @@ -1822,9 +1789,7 @@ mod tests { /// Walk a top-level SQL SELECT and return the bottom frame's /// `from_scope` after the walk completes. Used to verify that /// `writes_table` / `writes_table_alias` populate bindings. - fn from_scope_after_walk( - input: &str, - ) -> Vec { + fn from_scope_after_walk(input: &str) -> Vec { let mut ctx = WalkContext::new(); let mut path = MatchedPath::new(); let mut per_byte = Vec::new(); @@ -1871,9 +1836,7 @@ mod tests { #[test] fn join_pushes_a_second_binding() { - let bindings = from_scope_after_walk( - "select * from a join b on x = y", - ); + let bindings = from_scope_after_walk("select * from a join b on x = y"); assert_eq!(bindings.len(), 2); assert_eq!(bindings[0].table, "a"); assert_eq!(bindings[1].table, "b"); @@ -1881,9 +1844,7 @@ mod tests { #[test] fn join_with_aliases() { - let bindings = from_scope_after_walk( - "select * from a as x join b as y on x.id = y.id", - ); + let bindings = from_scope_after_walk("select * from a as x join b as y on x.id = y.id"); assert_eq!(bindings.len(), 2); assert_eq!(bindings[0].table, "a"); assert_eq!(bindings[0].alias, Some("x".to_string())); @@ -1893,9 +1854,8 @@ mod tests { #[test] fn three_way_join_pushes_three_bindings() { - let bindings = from_scope_after_walk( - "select * from a join b on x = y left join c on y = z", - ); + let bindings = + from_scope_after_walk("select * from a join b on x = y left join c on y = z"); assert_eq!(bindings.len(), 3); assert_eq!(bindings[0].table, "a"); assert_eq!(bindings[1].table, "b"); @@ -1908,9 +1868,8 @@ mod tests { // binding into the inner scope frame; on exit, the frame // pops and the inner binding is gone. The outer scope's // from_scope still contains only `outer_t`. - let bindings = from_scope_after_walk( - "select * from outer_t where id in (select id from inner_t)", - ); + let bindings = + from_scope_after_walk("select * from outer_t where id in (select id from inner_t)"); assert_eq!(bindings.len(), 1); assert_eq!(bindings[0].table, "outer_t"); } @@ -1921,9 +1880,8 @@ mod tests { // body's scope frame; on body-frame exit, the inner // binding goes away. The outer scope contains only // the CTE-name reference `cte_x`. - let bindings = from_scope_after_walk( - "with cte_x as (select * from base_table) select * from cte_x", - ); + let bindings = + from_scope_after_walk("with cte_x as (select * from base_table) select * from cte_x"); assert_eq!(bindings.len(), 1); assert_eq!(bindings[0].table, "cte_x"); } @@ -1940,10 +1898,7 @@ mod tests { /// `cte_bindings` and `projection_aliases` after the walk. fn frame_state_after_walk( input: &str, - ) -> ( - Vec, - Vec, - ) { + ) -> (Vec, Vec) { let mut ctx = WalkContext::new(); let mut path = MatchedPath::new(); let mut per_byte = Vec::new(); @@ -1968,9 +1923,7 @@ mod tests { #[test] fn cte_name_pushes_placeholder_binding() { - let (ctes, _) = frame_state_after_walk( - "with cte_x as (select 1) select * from cte_x", - ); + let (ctes, _) = frame_state_after_walk("with cte_x as (select 1) select * from cte_x"); assert_eq!(ctes.len(), 1); assert_eq!(ctes[0].name, "cte_x"); // §10.3 stage-2 harvest produces one CteColumn per @@ -1984,9 +1937,8 @@ mod tests { #[test] fn multiple_ctes_push_in_order() { - let (ctes, _) = frame_state_after_walk( - "with a as (select 1), b as (select 2) select * from b", - ); + let (ctes, _) = + frame_state_after_walk("with a as (select 1), b as (select 2) select * from b"); assert_eq!(ctes.len(), 2); assert_eq!(ctes[0].name, "a"); assert_eq!(ctes[1].name, "b"); @@ -2006,25 +1958,20 @@ mod tests { #[test] fn projection_aliases_captured_via_as_form() { - let (_, aliases) = frame_state_after_walk( - "select a as alpha, b as beta from t", - ); + let (_, aliases) = frame_state_after_walk("select a as alpha, b as beta from t"); assert_eq!(aliases, vec!["alpha".to_string(), "beta".to_string()]); } #[test] fn projection_aliases_captured_via_bare_form() { - let (_, aliases) = frame_state_after_walk( - "select a alpha, b beta from t", - ); + let (_, aliases) = frame_state_after_walk("select a alpha, b beta from t"); assert_eq!(aliases, vec!["alpha".to_string(), "beta".to_string()]); } #[test] fn projection_aliases_mixed_forms() { - let (_, aliases) = frame_state_after_walk( - "select a as alpha, b beta, c, d as delta from t", - ); + let (_, aliases) = + frame_state_after_walk("select a as alpha, b beta, c, d as delta from t"); assert_eq!( aliases, vec!["alpha".to_string(), "beta".to_string(), "delta".to_string()] @@ -2033,8 +1980,7 @@ mod tests { #[test] fn projection_aliases_empty_when_no_aliases() { - let (_, aliases) = - frame_state_after_walk("select a, b from t"); + let (_, aliases) = frame_state_after_walk("select a, b from t"); assert!(aliases.is_empty()); } @@ -2088,9 +2034,24 @@ mod tests { s.table_columns.insert( "users".to_string(), vec![ - TableColumn { name: "id".to_string(), user_type: Type::Int, not_null: false, has_default: false }, - TableColumn { name: "name".to_string(), user_type: Type::Text, not_null: false, has_default: false }, - TableColumn { name: "age".to_string(), user_type: Type::Int, not_null: false, has_default: false }, + TableColumn { + name: "id".to_string(), + user_type: Type::Int, + not_null: false, + has_default: false, + }, + TableColumn { + name: "name".to_string(), + user_type: Type::Text, + not_null: false, + has_default: false, + }, + TableColumn { + name: "age".to_string(), + user_type: Type::Int, + not_null: false, + has_default: false, + }, ], ); s @@ -2108,10 +2069,7 @@ mod tests { assert_eq!(ctes.len(), 1); assert_eq!(ctes[0].columns.len(), 3); assert_eq!(ctes[0].columns[0].name.as_deref(), Some("id")); - assert_eq!( - ctes[0].columns[0].type_, - Some(crate::dsl::types::Type::Int), - ); + assert_eq!(ctes[0].columns[0].type_, Some(crate::dsl::types::Type::Int),); assert_eq!(ctes[0].columns[1].name.as_deref(), Some("name")); assert_eq!( ctes[0].columns[1].type_, @@ -2161,10 +2119,7 @@ mod tests { ); assert_eq!(ctes[0].columns.len(), 1); assert_eq!(ctes[0].columns[0].name.as_deref(), Some("age")); - assert_eq!( - ctes[0].columns[0].type_, - Some(crate::dsl::types::Type::Int), - ); + assert_eq!(ctes[0].columns[0].type_, Some(crate::dsl::types::Type::Int),); } #[test] @@ -2259,15 +2214,9 @@ mod tests { .expect("outer_cte binding"); assert_eq!(outer.columns.len(), 2); assert_eq!(outer.columns[0].name.as_deref(), Some("id")); - assert_eq!( - outer.columns[0].type_, - Some(crate::dsl::types::Type::Int), - ); + assert_eq!(outer.columns[0].type_, Some(crate::dsl::types::Type::Int),); assert_eq!(outer.columns[1].name.as_deref(), Some("name")); - assert_eq!( - outer.columns[1].type_, - Some(crate::dsl::types::Type::Text), - ); + assert_eq!(outer.columns[1].type_, Some(crate::dsl::types::Type::Text),); } #[test] @@ -2287,15 +2236,9 @@ mod tests { let b = ctes.iter().find(|c| c.name == "b").expect("b binding"); assert_eq!(b.columns.len(), 2); assert_eq!(b.columns[0].name.as_deref(), Some("id")); - assert_eq!( - b.columns[0].type_, - Some(crate::dsl::types::Type::Int), - ); + assert_eq!(b.columns[0].type_, Some(crate::dsl::types::Type::Int),); assert_eq!(b.columns[1].name.as_deref(), Some("name")); - assert_eq!( - b.columns[1].type_, - Some(crate::dsl::types::Type::Text), - ); + assert_eq!(b.columns[1].type_, Some(crate::dsl::types::Type::Text),); } #[test] @@ -2310,10 +2253,7 @@ mod tests { ); assert_eq!(ctes[0].columns.len(), 3); assert_eq!(ctes[0].columns[0].name.as_deref(), Some("a")); - assert_eq!( - ctes[0].columns[0].type_, - Some(crate::dsl::types::Type::Int), - ); + assert_eq!(ctes[0].columns[0].type_, Some(crate::dsl::types::Type::Int),); assert_eq!(ctes[0].columns[1].name.as_deref(), Some("b")); assert_eq!( ctes[0].columns[1].type_, diff --git a/src/dsl/walker/highlight.rs b/src/dsl/walker/highlight.rs index 0a29ef4..40329a7 100644 --- a/src/dsl/walker/highlight.rs +++ b/src/dsl/walker/highlight.rs @@ -24,8 +24,8 @@ use crate::dsl::grammar::HighlightClass; use crate::dsl::walker::context::WalkContext; use crate::dsl::walker::lex_helpers::{ - consume_bare_path, consume_flag, consume_ident, consume_number_literal, - consume_string_literal, skip_whitespace, + consume_bare_path, consume_flag, consume_ident, consume_number_literal, consume_string_literal, + skip_whitespace, }; use crate::dsl::walker::outcome::{ByteClass, WalkBound}; @@ -47,16 +47,11 @@ pub fn highlight_runs(source: &str) -> Vec { /// token, producing the keyword classes the renderer needs to /// colour `select` / `from` / `where` / `union` / `case` / etc. #[must_use] -pub fn highlight_runs_in_mode( - source: &str, - mode: crate::mode::Mode, -) -> Vec { +pub fn highlight_runs_in_mode(source: &str, mode: crate::mode::Mode) -> Vec { let mut ctx = WalkContext::new(); ctx.mode = mode; let (result, _cmd) = super::walk(source, WalkBound::EndOfInput, &mut ctx); - let mut classes: Vec = result - .map(|r| r.per_byte_class) - .unwrap_or_default(); + let mut classes: Vec = result.map(|r| r.per_byte_class).unwrap_or_default(); let scan_start = classes.last().map_or(0, |c| c.end); scan_remainder(source, scan_start, &mut classes); @@ -133,9 +128,7 @@ fn scan_remainder(source: &str, start: usize, classes: &mut Vec) { .get(pos + 1) .copied() .is_some_and(|c| c.is_ascii_digit())); - if looks_like_number - && let Some((s, e)) = consume_number_literal(source, pos) - { + if looks_like_number && let Some((s, e)) = consume_number_literal(source, pos) { classes.push(ByteClass { start: s, end: e, @@ -222,8 +215,14 @@ mod tests { "no Error highlight on a valid m:n line: {runs:?}" ); let kinds: Vec = runs.iter().map(|(_, _, c)| *c).collect(); - assert!(kinds.contains(&HighlightClass::Keyword), "keywords highlighted: {runs:?}"); - assert!(kinds.contains(&HighlightClass::Identifier), "table names highlighted: {runs:?}"); + assert!( + kinds.contains(&HighlightClass::Keyword), + "keywords highlighted: {runs:?}" + ); + assert!( + kinds.contains(&HighlightClass::Identifier), + "table names highlighted: {runs:?}" + ); } #[test] @@ -276,10 +275,7 @@ mod tests { #[test] fn flag_classified_via_fallback() { // Walker doesn't engage for a bare `--all-rows`. - assert_eq!( - run("--all-rows"), - vec![(0, 10, HighlightClass::Flag)], - ); + assert_eq!(run("--all-rows"), vec![(0, 10, HighlightClass::Flag)],); } #[test] @@ -445,15 +441,13 @@ mod tests { // dispatcher, so only the entry word would highlight). let runs = run_advanced("select * from t"); assert!( - runs.iter().any(|(s, e, c)| { - *c == HighlightClass::Keyword && (*s, *e) == (0, 6) - }), + runs.iter() + .any(|(s, e, c)| { *c == HighlightClass::Keyword && (*s, *e) == (0, 6) }), "expected `select` keyword span 0..6; got {runs:?}", ); assert!( - runs.iter().any(|(s, e, c)| { - *c == HighlightClass::Keyword && (*s, *e) == (9, 13) - }), + runs.iter() + .any(|(s, e, c)| { *c == HighlightClass::Keyword && (*s, *e) == (9, 13) }), "expected `from` keyword span 9..13; got {runs:?}", ); } @@ -514,18 +508,37 @@ mod tests { let insert = keywords_of( "insert into t (a) values (1) on conflict (a) do update set a = excluded.a returning a", ); - for kw in ["insert", "into", "values", "on", "conflict", "do", "update", "set", "returning"] { - assert!(insert.contains(&kw), "INSERT/UPSERT: missing `{kw}`; got {insert:?}"); + for kw in [ + "insert", + "into", + "values", + "on", + "conflict", + "do", + "update", + "set", + "returning", + ] { + assert!( + insert.contains(&kw), + "INSERT/UPSERT: missing `{kw}`; got {insert:?}" + ); } let update = keywords_of("update t set a = 1 where id = 2 returning a"); for kw in ["update", "set", "where", "returning"] { - assert!(update.contains(&kw), "UPDATE: missing `{kw}`; got {update:?}"); + assert!( + update.contains(&kw), + "UPDATE: missing `{kw}`; got {update:?}" + ); } let delete = keywords_of("delete from t where id = 1 returning *"); for kw in ["delete", "from", "where", "returning"] { - assert!(delete.contains(&kw), "DELETE: missing `{kw}`; got {delete:?}"); + assert!( + delete.contains(&kw), + "DELETE: missing `{kw}`; got {delete:?}" + ); } } } diff --git a/src/dsl/walker/lex_helpers.rs b/src/dsl/walker/lex_helpers.rs index 9b24b61..0f6e80f 100644 --- a/src/dsl/walker/lex_helpers.rs +++ b/src/dsl/walker/lex_helpers.rs @@ -110,9 +110,7 @@ pub fn consume_number_literal(source: &str, start: usize) -> Option<(usize, usiz return None; } let mut i = start; - let leading_minus = bytes[i] == b'-' - && i + 1 < bytes.len() - && bytes[i + 1].is_ascii_digit(); + let leading_minus = bytes[i] == b'-' && i + 1 < bytes.len() && bytes[i + 1].is_ascii_digit(); if leading_minus { i += 1; } diff --git a/src/dsl/walker/mod.rs b/src/dsl/walker/mod.rs index 99a8e0b..d7b55c4 100644 --- a/src/dsl/walker/mod.rs +++ b/src/dsl/walker/mod.rs @@ -18,16 +18,12 @@ pub mod highlight; pub mod lex_helpers; pub mod outcome; -use crate::dsl::command::{ - Command, CompareOp, Expr, Operand, Predicate, RowFilter, -}; +use crate::dsl::command::{Command, CompareOp, Expr, Operand, Predicate, RowFilter}; use crate::dsl::grammar; use crate::dsl::walker::context::WalkContext; use crate::dsl::walker::driver::{FailureKind, NodeWalkResult, walk_node}; use crate::dsl::walker::lex_helpers::{consume_ident, skip_whitespace}; -use crate::dsl::walker::outcome::{ - Expectation, MatchedPath, WalkBound, WalkOutcome, WalkResult, -}; +use crate::dsl::walker::outcome::{Expectation, MatchedPath, WalkBound, WalkOutcome, WalkResult}; pub use context::ColumnInfo; pub use highlight::{highlight_runs, highlight_runs_in_mode}; @@ -161,9 +157,7 @@ pub fn hint_resolution_at_input_in_mode( // ident slots ("Type a name"). match snap.pending_hint_mode { Some( - mode @ (HintMode::ProseOnly(_) - | HintMode::ForceProse(_) - | HintMode::IntroProse(_)), + mode @ (HintMode::ProseOnly(_) | HintMode::ForceProse(_) | HintMode::IntroProse(_)), ) => Some(HintResolution { mode, column: None, @@ -196,7 +190,11 @@ fn form_b_autogen_skipped( if user_listed.is_some() { return Vec::new(); } - if !source.trim_start().to_ascii_lowercase().starts_with("insert") { + if !source + .trim_start() + .to_ascii_lowercase() + .starts_with("insert") + { return Vec::new(); } let Some(cols) = table_columns else { @@ -292,10 +290,7 @@ pub struct CompletionProbe { /// Run a schema-aware walk and report the completion-engine's /// view (ADR-0024 §Phase D §column-narrowing). #[must_use] -pub fn completion_probe( - source: &str, - schema: &crate::completion::SchemaCache, -) -> CompletionProbe { +pub fn completion_probe(source: &str, schema: &crate::completion::SchemaCache) -> CompletionProbe { completion_probe_in_mode(source, schema, crate::mode::Mode::Advanced) } @@ -321,8 +316,7 @@ pub fn completion_probe_in_mode( REGISTRY .iter() .filter(|(c, _)| { - mode == crate::mode::Mode::Advanced - || !is_advanced_only(c.entry.primary) + mode == crate::mode::Mode::Advanced || !is_advanced_only(c.entry.primary) }) .filter(|(c, _)| seen.insert(c.entry.primary)) .map(|(c, _)| outcome::Expectation::Word(c.entry.primary)) @@ -452,9 +446,7 @@ pub fn completion_probe_in_mode( for (_, node, category) in candidates { // Simple mode never offers advanced SQL continuations // (ADR-0030 §2); only DSL forms contribute. - if mode == crate::mode::Mode::Simple - && category == CommandCategory::Advanced - { + if mode == crate::mode::Mode::Simple && category == CommandCategory::Advanced { continue; } let mut sctx = context::WalkContext::with_schema(schema); @@ -468,8 +460,7 @@ pub fn completion_probe_in_mode( let advanced = category == CommandCategory::Advanced; for e in &cont { match e { - outcome::Expectation::Word(w) - | outcome::Expectation::Literal(w) => { + outcome::Expectation::Word(w) | outcome::Expectation::Literal(w) => { match tally.iter_mut().find(|(kw, _, _)| kw == w) { Some(rec) => { if advanced { @@ -580,10 +571,7 @@ pub fn input_verdict_in_mode( if source.trim().is_empty() { return None; } - let mut ctx = schema.map_or_else( - context::WalkContext::new, - context::WalkContext::with_schema, - ); + let mut ctx = schema.map_or_else(context::WalkContext::new, context::WalkContext::with_schema); ctx.mode = mode; let (result, _cmd) = walk(source, outcome::WalkBound::EndOfInput, &mut ctx); let Some(result) = result else { @@ -631,10 +619,7 @@ pub fn input_diagnostics_in_mode( if source.trim().is_empty() { return Vec::new(); } - let mut ctx = schema.map_or_else( - context::WalkContext::new, - context::WalkContext::with_schema, - ); + let mut ctx = schema.map_or_else(context::WalkContext::new, context::WalkContext::with_schema); ctx.mode = mode; let (result, _cmd) = walk(source, outcome::WalkBound::EndOfInput, &mut ctx); result.map_or_else(Vec::new, |r| r.diagnostics) @@ -668,10 +653,7 @@ struct PassBinding { /// Resolve a qualifier identifier against the active bindings. /// Aliases shadow base-table names (ADR-0032 §10.5), so alias /// matches are tried first. -fn resolve_qualifier<'a>( - bindings: &'a [PassBinding], - qualifier: &str, -) -> Option<&'a PassBinding> { +fn resolve_qualifier<'a>(bindings: &'a [PassBinding], qualifier: &str) -> Option<&'a PassBinding> { bindings .iter() .find(|b| { @@ -1340,9 +1322,7 @@ fn cte_names_contains(names: &[String], candidate: &str) -> bool { /// compared against it on that leg's close. The op token's span /// is the diagnostic anchor — that's the join point the learner /// pointed the chain at. -fn compound_arity_diagnostics( - path: &MatchedPath, -) -> Vec { +fn compound_arity_diagnostics(path: &MatchedPath) -> Vec { use outcome::{Diagnostic, MatchedKind, Severity}; use std::collections::HashMap; @@ -1420,11 +1400,13 @@ fn compound_arity_diagnostics( // supersedes any stale state. legs.insert( depth, - LegState { arity: 1, in_projection: true }, + LegState { + arity: 1, + in_projection: true, + }, ); } - "from" | "where" | "group" | "having" | "order" - | "limit" | "offset" => { + "from" | "where" | "group" | "having" | "order" | "limit" | "offset" => { if let Some(leg) = legs.get_mut(&depth) { leg.in_projection = false; } @@ -1433,12 +1415,9 @@ fn compound_arity_diagnostics( // Close the just-finished leg, comparing // it against any pending set-op state at // this depth. - if let Some(arity) = close_leg( - depth, - &mut legs, - &mut pending, - &mut diagnostics, - ) { + if let Some(arity) = + close_leg(depth, &mut legs, &mut pending, &mut diagnostics) + { pending.insert( depth, Pending { @@ -1593,20 +1572,17 @@ fn dml_insert_arity_diagnostics( // (insert-specific); the DSL grammar reuses the generic `table_name` // role, so only trust that when `into` confirmed an insert (issue // #17). - let target_table: Option<&str> = path - .items - .iter() - .find_map(|it| match it.kind { - MatchedKind::Ident { - source: IdentSource::Tables, - role: "insert_target_table", - } => Some(it.text.as_str()), - MatchedKind::Ident { - source: IdentSource::Tables, - role: "table_name", - } if is_insert => Some(it.text.as_str()), - _ => None, - }); + let target_table: Option<&str> = path.items.iter().find_map(|it| match it.kind { + MatchedKind::Ident { + source: IdentSource::Tables, + role: "insert_target_table", + } => Some(it.text.as_str()), + MatchedKind::Ident { + source: IdentSource::Tables, + role: "table_name", + } if is_insert => Some(it.text.as_str()), + _ => None, + }); // Resolve the expected arity + a message builder. The builder // captures the per-case args because the message key (and its @@ -1815,8 +1791,8 @@ fn dml_insert_arity_diagnostics( MatchedKind::Punct(')') => depth -= 1, MatchedKind::Punct(',') if depth == 0 => proj_arity += 1, MatchedKind::Word( - "from" | "where" | "group" | "having" | "order" | "limit" | "offset" - | "union" | "intersect" | "except" | "on" | "returning", + "from" | "where" | "group" | "having" | "order" | "limit" | "offset" | "union" + | "intersect" | "except" | "on" | "returning", ) if depth == 0 => break, _ => { if depth == 0 && anchor.is_none() { @@ -2102,9 +2078,9 @@ fn sql_predicate_warnings( continue; } // Resolve column → which binding's column → what type. - let Some(col_type) = resolve_bare_column_type( - &bindings, &cte_names, schema, &items[i].text, - ) else { + let Some(col_type) = + resolve_bare_column_type(&bindings, &cte_names, schema, &items[i].text) + else { // Unknown column or in a CTE-binding (whose columns // are unknown until harvest lands). Either way, skip. continue; @@ -2125,10 +2101,7 @@ fn sql_predicate_warnings( diagnostics.push(Diagnostic { severity: Severity::Warning, span: third.span, - message: crate::friendly::translate( - "diagnostic.eq_null", - &[], - ), + message: crate::friendly::translate("diagnostic.eq_null", &[]), }); continue; } @@ -2136,9 +2109,7 @@ fn sql_predicate_warnings( // ` LIKE ` — pedagogical: LIKE is a // text-pattern match, so a numeric column rarely makes // sense as the target. - if matches!(next.kind, MatchedKind::Word("like")) - && col_type.is_numeric() - { + if matches!(next.kind, MatchedKind::Word("like")) && col_type.is_numeric() { diagnostics.push(Diagnostic { severity: Severity::Warning, span: items[i].span, @@ -2157,8 +2128,7 @@ fn sql_predicate_warnings( // the literal's type is structurally incompatible with // the column's type. Conservative: only flag clear-cut // numeric-vs-text mismatches. - if let MatchedKind::Word(op @ ("=" | "!=" | "<>" | "<" | "<=" | ">" | ">=")) - = next.kind + if let MatchedKind::Word(op @ ("=" | "!=" | "<>" | "<" | "<=" | ">" | ">=")) = next.kind && let Some(third) = items.get(i + 2) { let _ = op; @@ -2235,23 +2205,18 @@ fn resolve_bare_column_type( /// half of a `t.c` qualified reference. Used by /// `schema_existence_diagnostics` to skip the bare-column check /// on qualifiers. -fn is_followed_by_qualified_ref( - items: &[outcome::MatchedItem], - i: usize, -) -> bool { +fn is_followed_by_qualified_ref(items: &[outcome::MatchedItem], i: usize) -> bool { use outcome::MatchedKind; let dot = items.get(i + 1); let next_ident = items.get(i + 2); - matches!( - dot.map(|it| &it.kind), - Some(MatchedKind::Punct('.')) - ) && matches!( - next_ident.map(|it| &it.kind), - Some(MatchedKind::Ident { - role: "sql_expr_qualified_ref", - .. - }) - ) + matches!(dot.map(|it| &it.kind), Some(MatchedKind::Punct('.'))) + && matches!( + next_ident.map(|it| &it.kind), + Some(MatchedKind::Ident { + role: "sql_expr_qualified_ref", + .. + }) + ) } /// True when the ident at `items[i]` is the *name* of a function call — @@ -2262,10 +2227,7 @@ fn is_followed_by_qualified_ref( /// structurally (sql_expr.rs's CALL_ARGS comment: "it does not know /// which names are aggregates"), so this validator must match: an /// ident followed by `(` is a function-call name, not a column ref. -fn is_followed_by_call_args( - items: &[outcome::MatchedItem], - i: usize, -) -> bool { +fn is_followed_by_call_args(items: &[outcome::MatchedItem], i: usize) -> bool { use outcome::MatchedKind; matches!( items.get(i + 1).map(|it| &it.kind), @@ -2289,11 +2251,7 @@ fn schema_column_type( .map(|c| c.user_type) } -fn schema_has_column( - schema: &crate::completion::SchemaCache, - table: &str, - column: &str, -) -> bool { +fn schema_has_column(schema: &crate::completion::SchemaCache, table: &str, column: &str) -> bool { schema .columns_for_table(table) .is_some_and(|cols| cols.iter().any(|c| c.name.eq_ignore_ascii_case(column))) @@ -2376,9 +2334,7 @@ fn predicate_warnings( crate::friendly::translate("diagnostic.eq_null", &[]), operand.span(), )); - } else if let Some((message, span)) = - pair_type_mismatch(left, right, columns) - { + } else if let Some((message, span)) = pair_type_mismatch(left, right, columns) { out.push(warn(message, span)); } } @@ -2386,18 +2342,14 @@ fn predicate_warnings( target, low, high, .. } => { for bound in [low, high] { - if let Some((message, span)) = - pair_type_mismatch(target, bound, columns) - { + if let Some((message, span)) = pair_type_mismatch(target, bound, columns) { out.push(warn(message, span)); } } } Predicate::In { target, items, .. } => { for item in items { - if let Some((message, span)) = - pair_type_mismatch(target, item, columns) - { + if let Some((message, span)) = pair_type_mismatch(target, item, columns) { out.push(warn(message, span)); } } @@ -2407,9 +2359,7 @@ fn predicate_warnings( // (ADR-0027, Amendment 1). The negation is irrelevant — // `NOT LIKE` on a numeric column is just as dubious. Predicate::Like { target, .. } => { - if let Some((message, span)) = - like_numeric_warning(target, columns) - { + if let Some((message, span)) = like_numeric_warning(target, columns) { out.push(warn(message, span)); } } @@ -2471,14 +2421,8 @@ fn pair_type_mismatch( columns: &[crate::completion::TableColumn], ) -> Option<(String, (usize, usize))> { let (column, literal, span) = match (a, b) { - ( - Operand::Column { name, .. }, - Operand::Literal { value, span }, - ) - | ( - Operand::Literal { value, span }, - Operand::Column { name, .. }, - ) => (name, value, *span), + (Operand::Column { name, .. }, Operand::Literal { value, span }) + | (Operand::Literal { value, span }, Operand::Column { name, .. }) => (name, value, *span), _ => return None, }; // `null` fits any column; `= NULL` is flagged separately. @@ -2543,8 +2487,7 @@ pub fn expected_at_input_in_mode( REGISTRY .iter() .filter(|(c, _)| { - mode == crate::mode::Mode::Advanced - || !is_advanced_only(c.entry.primary) + mode == crate::mode::Mode::Advanced || !is_advanced_only(c.entry.primary) }) .filter(|(c, _)| seen.insert(c.entry.primary)) .map(|(c, _)| outcome::Expectation::Word(c.entry.primary)) @@ -2969,8 +2912,15 @@ fn scratch_outcome( ) -> WalkOutcome { let mut sctx = schema.map_or_else(context::WalkContext::new, context::WalkContext::with_schema); sctx.mode = mode; - let (result, _cmd) = - walk_one_command(effective_source, effective_source, kw_start, kw_end, 0, node, &mut sctx); + let (result, _cmd) = walk_one_command( + effective_source, + effective_source, + kw_start, + kw_end, + 0, + node, + &mut sctx, + ); result.outcome } @@ -3078,21 +3028,23 @@ fn walk_one_command<'a>( // the command really wanted something more.) let post = skip_whitespace(effective_source, position); if post >= effective_source.len() { - WalkOutcome::Incomplete { position: post, expected } + WalkOutcome::Incomplete { + position: post, + expected, + } } else { - WalkOutcome::Mismatch { position: post, expected } + WalkOutcome::Mismatch { + position: post, + expected, + } } } NodeWalkResult::Incomplete { position, expected } => { WalkOutcome::Incomplete { position, expected } } NodeWalkResult::Failed { position, kind } => match kind { - FailureKind::Mismatch { expected } => { - WalkOutcome::Mismatch { position, expected } - } - FailureKind::Validation(error) => { - WalkOutcome::ValidationFailed { position, error } - } + FailureKind::Mismatch { expected } => WalkOutcome::Mismatch { position, expected }, + FailureKind::Validation(error) => WalkOutcome::ValidationFailed { position, error }, }, }; @@ -3105,10 +3057,7 @@ fn walk_one_command<'a>( Ok(c) => (outcome, Some(c)), Err(error) => ( WalkOutcome::ValidationFailed { - position: path - .items - .last() - .map_or(kw_start, |i| i.span.0), + position: path.items.last().map_or(kw_start, |i| i.span.0), error, }, None, @@ -3228,7 +3177,10 @@ mod tests { #[test] fn walker_parses_help() { - assert_eq!(parse("help").unwrap(), Command::App(AppCommand::Help { topic: None })); + assert_eq!( + parse("help").unwrap(), + Command::App(AppCommand::Help { topic: None }) + ); } #[test] @@ -3539,7 +3491,10 @@ mod tests { }; assert_eq!(parse("drop column Customers: Email").unwrap(), want); assert_eq!(parse("drop column from Customers: Email").unwrap(), want); - assert_eq!(parse("drop column from table Customers: Email").unwrap(), want); + assert_eq!( + parse("drop column from table Customers: Email").unwrap(), + want + ); assert_eq!(parse("drop column table Customers: Email").unwrap(), want); } @@ -3799,8 +3754,8 @@ mod tests { // Phase D — data commands (show, insert, update, delete). // ========================================================= - use crate::dsl::value::Value; use crate::dsl::command::RowFilter; + use crate::dsl::value::Value; #[test] fn walker_parses_show_data() { @@ -3854,9 +3809,7 @@ mod tests { fn walker_parses_update_with_complex_where() { // The WHERE is a full boolean expression, not a single // equality (ADR-0026). - match parse("update T set Active=true where Age>30 and Name like 'A%'") - .unwrap() - { + match parse("update T set Active=true where Age>30 and Name like 'A%'").unwrap() { Command::Update { filter: RowFilter::Where(crate::dsl::Expr::And(terms)), .. @@ -3928,26 +3881,18 @@ mod tests { #[test] fn input_verdict_unknown_column_is_error() { - let schema = - schema_with("Customers", &[("id", Type::Int), ("Name", Type::Text)]); + let schema = schema_with("Customers", &[("id", Type::Int), ("Name", Type::Text)]); assert_eq!( - super::input_verdict( - "show data Customers where NoSuchCol = 1", - Some(&schema), - ), + super::input_verdict("show data Customers where NoSuchCol = 1", Some(&schema),), Some(super::Severity::Error), ); } #[test] fn input_verdict_known_table_and_column_is_clean() { - let schema = - schema_with("Customers", &[("id", Type::Int), ("Name", Type::Text)]); + let schema = schema_with("Customers", &[("id", Type::Int), ("Name", Type::Text)]); assert_eq!( - super::input_verdict( - "show data Customers where id = 1", - Some(&schema), - ), + super::input_verdict("show data Customers where id = 1", Some(&schema),), None, ); } @@ -3956,39 +3901,27 @@ mod tests { fn input_verdict_type_mismatch_is_warning() { // `Age` is int; comparing it with a text literal runs, // but is flagged (ADR-0026 §7). - let schema = - schema_with("Customers", &[("id", Type::Int), ("Age", Type::Int)]); + let schema = schema_with("Customers", &[("id", Type::Int), ("Age", Type::Int)]); assert_eq!( - super::input_verdict( - "delete from Customers where Age = 'hello'", - Some(&schema), - ), + super::input_verdict("delete from Customers where Age = 'hello'", Some(&schema),), Some(super::Severity::Warning), ); } #[test] fn input_verdict_eq_null_is_warning() { - let schema = - schema_with("Customers", &[("id", Type::Int), ("Name", Type::Text)]); + let schema = schema_with("Customers", &[("id", Type::Int), ("Name", Type::Text)]); assert_eq!( - super::input_verdict( - "delete from Customers where Name = null", - Some(&schema), - ), + super::input_verdict("delete from Customers where Name = null", Some(&schema),), Some(super::Severity::Warning), ); } #[test] fn input_verdict_compatible_comparison_is_clean() { - let schema = - schema_with("Customers", &[("id", Type::Int), ("Name", Type::Text)]); + let schema = schema_with("Customers", &[("id", Type::Int), ("Name", Type::Text)]); assert_eq!( - super::input_verdict( - "delete from Customers where id = 5", - Some(&schema), - ), + super::input_verdict("delete from Customers where id = 5", Some(&schema),), None, ); } @@ -4028,10 +3961,7 @@ mod tests { #[test] fn input_verdict_sweep_unknown_column_across_commands() { - let schema = schema_with( - "Customers", - &[("id", Type::Int), ("Name", Type::Text)], - ); + let schema = schema_with("Customers", &[("id", Type::Int), ("Name", Type::Text)]); for input in [ "drop column from table Customers: NoSuchCol", "update Customers set NoSuchCol = 1 where id = 1", @@ -4046,10 +3976,7 @@ mod tests { #[test] fn input_verdict_known_entities_across_commands_are_clean() { - let schema = schema_with( - "Customers", - &[("id", Type::Int), ("Name", Type::Text)], - ); + let schema = schema_with("Customers", &[("id", Type::Int), ("Name", Type::Text)]); for input in [ "show table Customers", "drop table Customers", @@ -4068,13 +3995,9 @@ mod tests { /// Walk `input` with `schema` and return the diagnostics the /// walk produced. - fn diagnostics( - input: &str, - schema: &SchemaCache, - ) -> Vec { + fn diagnostics(input: &str, schema: &SchemaCache) -> Vec { let mut ctx = super::context::WalkContext::with_schema(schema); - let (result, _cmd) = - super::walk(input, super::outcome::WalkBound::EndOfInput, &mut ctx); + let (result, _cmd) = super::walk(input, super::outcome::WalkBound::EndOfInput, &mut ctx); result.map_or_else(Vec::new, |r| r.diagnostics) } @@ -4113,8 +4036,7 @@ mod tests { let input = "delete from Customers where Age between 'a' and 'z'"; let diags = diagnostics(input, &schema); assert_eq!(diags.len(), 2); - let spans: Vec<&str> = - diags.iter().map(|d| &input[d.span.0..d.span.1]).collect(); + let spans: Vec<&str> = diags.iter().map(|d| &input[d.span.0..d.span.1]).collect(); assert_eq!(spans, vec!["'a'", "'z'"]); } @@ -4179,10 +4101,7 @@ mod tests { // `LIKE 'A%'` on a text column is its intended use. let schema = schema_with("Customers", &[("Name", Type::Text)]); assert_eq!( - super::input_verdict( - "delete from Customers where Name like 'A%'", - Some(&schema), - ), + super::input_verdict("delete from Customers where Name like 'A%'", Some(&schema),), None, ); } @@ -4194,7 +4113,10 @@ mod tests { Command::Insert { table: "Customers".to_string(), columns: Some(vec!["Email".to_string(), "Name".to_string()]), - values: vec![Value::Text("a@b.c".to_string()), Value::Text("Alice".to_string())], + values: vec![ + Value::Text("a@b.c".to_string()), + Value::Text("Alice".to_string()) + ], } ); } @@ -4392,8 +4314,8 @@ mod tests { // hint_mode_at_input (ADR-0024 §HintMode-per-node) // ========================================================= - use crate::dsl::grammar::HintMode; use super::hint_mode_at_input; + use crate::dsl::grammar::HintMode; #[test] fn hint_mode_value_literal_slot_after_insert_open_paren() { @@ -4507,14 +4429,16 @@ mod tests { // auto-fills it (ADR-0018 §3). let schema = schema_with( "Customers", - &[("id", Type::Serial), ("Name", Type::Text), ("Active", Type::Bool)], + &[ + ("id", Type::Serial), + ("Name", Type::Text), + ("Active", Type::Bool), + ], ); // 2 user-typed values: Name (text), Active (bool). - let cmd = parse_command_with_schema( - "insert into Customers values ('Alice', true)", - &schema, - ) - .expect("parse"); + let cmd = + parse_command_with_schema("insert into Customers values ('Alice', true)", &schema) + .expect("parse"); match cmd { Command::Insert { table, values, .. } => { assert_eq!(table, "Customers"); @@ -4534,19 +4458,15 @@ mod tests { // mismatch is now reported as an ERROR diagnostic rather than a // bare parse error, matching advanced mode. Dispatch is gated by // the submit pre-flight, not by a parse failure. - let schema = schema_with( - "Customers", - &[("id", Type::Serial), ("Name", Type::Text)], - ); + let schema = schema_with("Customers", &[("id", Type::Serial), ("Name", Type::Text)]); // Two values where Form B expects one (Name only): structurally // parses, but the simple-mode arity diagnostic flags it (Form B // expects 1 value for `Name`; `id` is auto-generated). - let diags = diag_keys_simple( - "insert into Customers values (1, 'Alice')", - &schema, - ); + let diags = diag_keys_simple("insert into Customers values (1, 'Alice')", &schema); assert!( - diags.iter().any(|d| d.contains("1 value(s)") && d.contains("2 given")), + diags + .iter() + .any(|d| d.contains("1 value(s)") && d.contains("2 given")), "Form B serial-skip count mismatch must fire the arity \ diagnostic (expected 1, got 2); got {diags:?}", ); @@ -4557,18 +4477,20 @@ mod tests { // Form A: user explicitly lists `id`. The dispatch path // accepts user-supplied serial values when they're in // the explicit column list; the grammar mirrors that. - let schema = schema_with( - "Customers", - &[("id", Type::Serial), ("Name", Type::Text)], - ); + let schema = schema_with("Customers", &[("id", Type::Serial), ("Name", Type::Text)]); let cmd = parse_command_with_schema( "insert into Customers (id, Name) values (1, 'Alice')", &schema, ) .expect("parse"); match cmd { - Command::Insert { columns, values, .. } => { - assert_eq!(columns.as_deref(), Some(&["id".to_string(), "Name".to_string()][..])); + Command::Insert { + columns, values, .. + } => { + assert_eq!( + columns.as_deref(), + Some(&["id".to_string(), "Name".to_string()][..]) + ); assert_eq!(values.len(), 2); } other => panic!("expected Insert, got {other:?}"), @@ -4582,15 +4504,19 @@ mod tests { // columns. let schema = schema_with( "Customers", - &[("id", Type::Serial), ("Name", Type::Text), ("Active", Type::Bool)], + &[ + ("id", Type::Serial), + ("Name", Type::Text), + ("Active", Type::Bool), + ], ); - let cmd = parse_command_with_schema( - "insert into Customers (Name) values ('Alice')", - &schema, - ) - .expect("parse"); + let cmd = + parse_command_with_schema("insert into Customers (Name) values ('Alice')", &schema) + .expect("parse"); match cmd { - Command::Insert { columns, values, .. } => { + Command::Insert { + columns, values, .. + } => { assert_eq!(columns.as_deref(), Some(&["Name".to_string()][..])); assert_eq!(values.len(), 1); } @@ -4624,11 +4550,8 @@ mod tests { "T", &[("a", Type::Int), ("b", Type::Text), ("c", Type::Bool)], ); - let cmd = parse_command_with_schema( - "insert into T values (null, null, null)", - &schema, - ) - .expect("parse"); + let cmd = parse_command_with_schema("insert into T values (null, null, null)", &schema) + .expect("parse"); match cmd { Command::Insert { values, .. } => { assert!(values.iter().all(|v| matches!(v, Value::Null))); @@ -4644,11 +4567,8 @@ mod tests { // back to the schemaless generic value-literal list and // accepts mixed-shape values as it did pre-Phase-D. let schema = SchemaCache::default(); - let cmd = parse_command_with_schema( - "insert into Customers values (1, 'Alice')", - &schema, - ) - .expect("parse — fallback path"); + let cmd = parse_command_with_schema("insert into Customers values (1, 'Alice')", &schema) + .expect("parse — fallback path"); match cmd { Command::Insert { values, .. } => assert_eq!(values.len(), 2), other => panic!("expected Insert, got {other:?}"), @@ -4670,8 +4590,8 @@ mod tests { #[test] fn phase_d_insert_accepts_bool_value_for_bool_column() { let schema = schema_with("T", &[("flag", Type::Bool)]); - let cmd = parse_command_with_schema("insert into T values (false)", &schema) - .expect("parse"); + let cmd = + parse_command_with_schema("insert into T values (false)", &schema).expect("parse"); match cmd { Command::Insert { values, .. } => { assert_eq!(values, vec![Value::Bool(false)]); @@ -4682,15 +4602,10 @@ mod tests { #[test] fn phase_d_update_accepts_text_value_for_text_column() { - let schema = schema_with( - "Customers", - &[("id", Type::Int), ("Email", Type::Text)], - ); - let cmd = parse_command_with_schema( - "update Customers set Email='new@b.c' where id=1", - &schema, - ) - .expect("parse"); + let schema = schema_with("Customers", &[("id", Type::Int), ("Email", Type::Text)]); + let cmd = + parse_command_with_schema("update Customers set Email='new@b.c' where id=1", &schema) + .expect("parse"); match cmd { Command::Update { assignments, .. } => { assert_eq!(assignments.len(), 1); @@ -4704,15 +4619,9 @@ mod tests { fn phase_d_update_rejects_decimal_in_int_set_column() { // Email is text; Score is int. Assigning `3.14` to Score // hits the int_slot validator. - let schema = schema_with( - "T", - &[("id", Type::Int), ("Score", Type::Int)], - ); - let err = parse_command_with_schema( - "update T set Score=3.14 where id=1", - &schema, - ) - .expect_err("should reject"); + let schema = schema_with("T", &[("id", Type::Int), ("Score", Type::Int)]); + let err = parse_command_with_schema("update T set Score=3.14 where id=1", &schema) + .expect_err("should reject"); match err { crate::dsl::ParseError::Invalid { message, .. } => { assert!( @@ -4728,8 +4637,7 @@ mod tests { fn phase_d_delete_where_uses_typed_column_value() { // `where id=1` — id is Int; `1` matches the int_slot. let schema = schema_with("T", &[("id", Type::Int), ("Name", Type::Text)]); - let cmd = parse_command_with_schema("delete from T where id=1", &schema) - .expect("parse"); + let cmd = parse_command_with_schema("delete from T where id=1", &schema).expect("parse"); match cmd { Command::Delete { .. } => {} other => panic!("expected Delete, got {other:?}"), @@ -4745,7 +4653,10 @@ mod tests { let schema = schema_with("T", &[("id", Type::Int)]); let cmd = parse_command_with_schema("delete from T where id=3.14", &schema) .expect("type-mismatched WHERE comparisons are permissive"); - assert!(matches!(cmd, crate::dsl::Command::Delete { .. }), "got {cmd:?}"); + assert!( + matches!(cmd, crate::dsl::Command::Delete { .. }), + "got {cmd:?}" + ); } // ---- Typed-slot HintMode (Phase D + HintMode dispatch) ---- @@ -4754,10 +4665,7 @@ mod tests { #[test] fn typed_hint_at_insert_first_value_position_for_int_column() { - let schema = schema_with( - "Customers", - &[("id", Type::Int), ("Name", Type::Text)], - ); + let schema = schema_with("Customers", &[("id", Type::Int), ("Name", Type::Text)]); match hint_mode_at_input_with_schema("insert into Customers values (", &schema) { Some(HintMode::ProseOnly("hint.value_slot_int")) => {} other => panic!("expected ProseOnly value_slot_int, got {other:?}"), @@ -4766,10 +4674,7 @@ mod tests { #[test] fn typed_hint_at_insert_second_value_position_for_text_column() { - let schema = schema_with( - "Customers", - &[("id", Type::Int), ("Name", Type::Text)], - ); + let schema = schema_with("Customers", &[("id", Type::Int), ("Name", Type::Text)]); match hint_mode_at_input_with_schema("insert into Customers values (1, ", &schema) { Some(HintMode::ProseOnly("hint.value_slot_text")) => {} other => panic!("expected ProseOnly value_slot_text, got {other:?}"), @@ -4778,10 +4683,7 @@ mod tests { #[test] fn typed_hint_at_update_set_value_uses_column_type() { - let schema = schema_with( - "Customers", - &[("id", Type::Int), ("Email", Type::Text)], - ); + let schema = schema_with("Customers", &[("id", Type::Int), ("Email", Type::Text)]); match hint_mode_at_input_with_schema("update Customers set Email=", &schema) { Some(HintMode::ProseOnly("hint.value_slot_text")) => {} other => panic!("expected ProseOnly value_slot_text, got {other:?}"), @@ -4790,10 +4692,7 @@ mod tests { #[test] fn typed_hint_at_update_set_value_for_int_column() { - let schema = schema_with( - "Customers", - &[("id", Type::Int), ("Score", Type::Int)], - ); + let schema = schema_with("Customers", &[("id", Type::Int), ("Score", Type::Int)]); match hint_mode_at_input_with_schema("update Customers set Score=", &schema) { Some(HintMode::ProseOnly("hint.value_slot_int")) => {} other => panic!("expected ProseOnly value_slot_int, got {other:?}"), @@ -4867,8 +4766,7 @@ mod tests { (Type::ShortId, "hint.value_slot_shortid"), ] { let schema = schema_with("T", &[("c", ty)]); - let mode = - hint_mode_at_input_with_schema("insert into T (c) values (", &schema); + let mode = hint_mode_at_input_with_schema("insert into T (c) values (", &schema); assert!( matches!(mode, Some(HintMode::ProseOnly(k)) if k == key), "expected ProseOnly({key}) for type {ty:?}, got {mode:?}", @@ -4972,11 +4870,7 @@ mod tests { // walk manually so we can set the right mode. let mut ctx = super::context::WalkContext::with_schema(schema); ctx.mode = crate::mode::Mode::Advanced; - let (result, _cmd) = super::walk( - source, - super::outcome::WalkBound::EndOfInput, - &mut ctx, - ); + let (result, _cmd) = super::walk(source, super::outcome::WalkBound::EndOfInput, &mut ctx); let diagnostics = result.map_or_else(Vec::new, |r| r.diagnostics); diagnostics .into_iter() @@ -4990,8 +4884,7 @@ mod tests { fn diag_keys_simple(source: &str, schema: &SchemaCache) -> Vec { let mut ctx = super::context::WalkContext::with_schema(schema); ctx.mode = crate::mode::Mode::Simple; - let (result, _cmd) = - super::walk(source, super::outcome::WalkBound::EndOfInput, &mut ctx); + let (result, _cmd) = super::walk(source, super::outcome::WalkBound::EndOfInput, &mut ctx); result.map_or_else(Vec::new, |r| { r.diagnostics.into_iter().map(|d| d.message).collect() }) @@ -5014,12 +4907,11 @@ mod tests { ("SerNo", Type::Serial), ], ); - let diags = diag_keys_simple( - "insert into Customers values ('Oli', 52, 3)", - &schema, - ); + let diags = diag_keys_simple("insert into Customers values ('Oli', 52, 3)", &schema); assert!( - diags.iter().any(|d| d.contains("2 value(s)") && d.contains("3 given")), + diags + .iter() + .any(|d| d.contains("2 value(s)") && d.contains("3 given")), "simple Form B must fire arity diagnostic with user-fillable \ count (2) and actual (3); got {diags:?}", ); @@ -5044,14 +4936,17 @@ mod tests { // `insert_column`). let schema = schema_with( "Customers", - &[("id", Type::Serial), ("Name", Type::Text), ("Age", Type::Int)], - ); - let diags = diag_keys_simple( - "insert into Customers (Name, Age) values ('Oli')", - &schema, + &[ + ("id", Type::Serial), + ("Name", Type::Text), + ("Age", Type::Int), + ], ); + let diags = diag_keys_simple("insert into Customers (Name, Age) values ('Oli')", &schema); assert!( - diags.iter().any(|d| d.contains("names 2 column(s)") && d.contains("1 value(s)")), + diags + .iter() + .any(|d| d.contains("names 2 column(s)") && d.contains("1 value(s)")), "simple Form A must fire the column-list arity diagnostic; got {diags:?}", ); } @@ -5063,14 +4958,17 @@ mod tests { // though there is no `values` keyword to anchor on (issue #17). let schema = schema_with( "Customers", - &[("id", Type::Serial), ("Name", Type::Text), ("Age", Type::Int)], - ); - let diags = diag_keys_simple( - "insert into Customers ('Oli', 52, 3)", - &schema, + &[ + ("id", Type::Serial), + ("Name", Type::Text), + ("Age", Type::Int), + ], ); + let diags = diag_keys_simple("insert into Customers ('Oli', 52, 3)", &schema); assert!( - diags.iter().any(|d| d.contains("2 value(s)") && d.contains("3 given")), + diags + .iter() + .any(|d| d.contains("2 value(s)") && d.contains("3 given")), "simple Form C must fire the arity diagnostic (expected 2, got 3); got {diags:?}", ); } @@ -5080,17 +4978,13 @@ mod tests { // Edge: every column auto-generated (all serial/shortid). The // user-fillable count is 0, so any supplied value is a mismatch — // the tailored "all auto-generated" message fires (issue #17). - let schema = schema_with( - "Counters", - &[("id", Type::Serial), ("seq", Type::Serial)], - ); - let diags = diag_keys_simple( - "insert into Counters values (1)", - &schema, - ); + let schema = schema_with("Counters", &[("id", Type::Serial), ("seq", Type::Serial)]); + let diags = diag_keys_simple("insert into Counters values (1)", &schema); assert!( - diags.iter().any(|d| d.contains("all auto-generated") - || d.contains("auto-generated, so no values")), + diags + .iter() + .any(|d| d.contains("all auto-generated") + || d.contains("auto-generated, so no values")), "all-auto table must fire the tailored message; got {diags:?}", ); } @@ -5112,7 +5006,9 @@ mod tests { ); let diags = diag_keys("insert into Customers values ('Oli', 52, 3)", &schema); assert!( - diags.iter().any(|d| d.contains("all 4 column(s)") && d.contains("3 value(s)")), + diags + .iter() + .any(|d| d.contains("all 4 column(s)") && d.contains("3 value(s)")), "advanced Form B must keep the full-column count (4); got {diags:?}", ); } @@ -5143,24 +5039,18 @@ mod tests { fn unambiguous_bare_column_no_error() { let schema = two_table_schema(); // `name` is only in `a`; `total` is only in `b` — no ambiguity. - let diags = diag_keys( - "select name, total from a join b on a.id = b.id", - &schema, - ); - assert!( - diags.is_empty(), - "expected no diagnostics; got {diags:?}", - ); + let diags = diag_keys("select name, total from a join b on a.id = b.id", &schema); + assert!(diags.is_empty(), "expected no diagnostics; got {diags:?}",); } #[test] fn qualified_refs_in_join_on_resolve_cleanly() { let schema = two_table_schema(); - let diags = diag_keys("select a.name, b.total from a join b on a.id = b.id", &schema); - assert!( - diags.is_empty(), - "expected no diagnostics; got {diags:?}", + let diags = diag_keys( + "select a.name, b.total from a join b on a.id = b.id", + &schema, ); + assert!(diags.is_empty(), "expected no diagnostics; got {diags:?}",); } #[test] @@ -5287,7 +5177,10 @@ mod tests { // target-only column must resolve to the target `a`, not be // flagged against the SELECT source `b`. let schema = two_table_schema(); // a(id,name), b(id,total) - let diags = diag_keys("insert into a (id) select id from b returning name", &schema); + let diags = diag_keys( + "insert into a (id) select id from b returning name", + &schema, + ); assert!( !diags.iter().any(|d| d.contains("no such column")), "RETURNING `name` (a's column) must not flag against `b`; got {diags:?}", @@ -5310,7 +5203,10 @@ mod tests { #[test] fn unrelated_qualified_star_in_returning_still_flagged() { let schema = two_table_schema(); - let diags = diag_keys("insert into a (id) select id from b returning zzz.*", &schema); + let diags = diag_keys( + "insert into a (id) select id from b returning zzz.*", + &schema, + ); assert!( diags.iter().any(|d| d.contains("no such table or alias")), "unrelated `zzz.*` qualifier should still flag; got {diags:?}", @@ -5324,7 +5220,10 @@ mod tests { // target `a`, not flagged as an unknown qualifier (a is the // target, b is the SELECT source). let schema = two_table_schema(); - let diags = diag_keys("insert into a (id) select id from b returning a.name", &schema); + let diags = diag_keys( + "insert into a (id) select id from b returning a.name", + &schema, + ); assert!( diags.is_empty(), "target-qualified RETURNING ref must resolve cleanly; got {diags:?}", @@ -5335,7 +5234,10 @@ mod tests { fn target_qualified_ref_unknown_column_still_flagged() { // `a.nope` — a is the target but nope isn't its column. let schema = two_table_schema(); - let diags = diag_keys("insert into a (id) select id from b returning a.nope", &schema); + let diags = diag_keys( + "insert into a (id) select id from b returning a.nope", + &schema, + ); assert!( diags.iter().any(|d| d.contains("no such column")), "unknown column under the target qualifier should flag; got {diags:?}", @@ -5347,7 +5249,10 @@ mod tests { // A qualifier that's neither `excluded` nor the target is // still an unknown qualifier (the leak guard holds). let schema = two_table_schema(); - let diags = diag_keys("insert into a (id) select id from b returning zzz.name", &schema); + let diags = diag_keys( + "insert into a (id) select id from b returning zzz.name", + &schema, + ); assert!( diags.iter().any(|d| d.contains("no such table or alias")), "unrelated qualifier should still flag; got {diags:?}", @@ -5359,7 +5264,10 @@ mod tests { // Flip side: a RETURNING ref to a column on neither table is // flagged (against the INSERT target). let schema = two_table_schema(); - let diags = diag_keys("insert into a (id) select id from b returning nope", &schema); + let diags = diag_keys( + "insert into a (id) select id from b returning nope", + &schema, + ); assert!( diags.iter().any(|d| d.contains("no such column")), "unknown RETURNING column should be flagged; got {diags:?}", @@ -5372,9 +5280,15 @@ mod tests { // a valid target column is silent, an unknown one flags. let schema = schema_with("t", &[("a", Type::Int), ("b", Type::Text)]); let ok = diag_keys("insert into t (a) values (1) returning b", &schema); - assert!(!ok.iter().any(|d| d.contains("no such column")), "got {ok:?}"); + assert!( + !ok.iter().any(|d| d.contains("no such column")), + "got {ok:?}" + ); let bad = diag_keys("insert into t (a) values (1) returning nope", &schema); - assert!(bad.iter().any(|d| d.contains("no such column")), "got {bad:?}"); + assert!( + bad.iter().any(|d| d.contains("no such column")), + "got {bad:?}" + ); } #[test] @@ -5473,7 +5387,10 @@ mod tests { // `b` is NOT NULL with no default and is omitted → WARNING. let schema = schema_required( "t", - &[("a", Type::Int, false, false), ("b", Type::Text, true, false)], + &[ + ("a", Type::Int, false, false), + ("b", Type::Text, true, false), + ], ); let diags = diag_keys("insert into t (a) values (1)", &schema); assert!( @@ -5486,7 +5403,10 @@ mod tests { fn not_null_missing_silent_when_included() { let schema = schema_required( "t", - &[("a", Type::Int, false, false), ("b", Type::Text, true, false)], + &[ + ("a", Type::Int, false, false), + ("b", Type::Text, true, false), + ], ); let diags = diag_keys("insert into t (a, b) values (1, 'x')", &schema); assert!( @@ -5500,7 +5420,10 @@ mod tests { // NOT NULL but DEFAULT present → omitting is fine. let schema = schema_required( "t", - &[("a", Type::Int, false, false), ("b", Type::Text, true, true)], + &[ + ("a", Type::Int, false, false), + ("b", Type::Text, true, true), + ], ); let diags = diag_keys("insert into t (a) values (1)", &schema); assert!( @@ -5515,7 +5438,10 @@ mod tests { // omitting it is correct — not a missing required value. let schema = schema_required( "t", - &[("id", Type::Serial, true, false), ("b", Type::Text, false, false)], + &[ + ("id", Type::Serial, true, false), + ("b", Type::Text, false, false), + ], ); let diags = diag_keys("insert into t (b) values ('x')", &schema); assert!( @@ -5551,10 +5477,7 @@ mod tests { ("SerNo", Type::Serial), ], ); - let diags = diag_keys( - "insert into Customers values ('Oli', 52, 3)", - &schema, - ); + let diags = diag_keys("insert into Customers values ('Oli', 52, 3)", &schema); assert!( diags.iter().any(|d| d.contains("value(s) are given")), "Form B under-supply must fire arity diagnostic; got {diags:?}", @@ -5597,10 +5520,7 @@ mod tests { ("SerNo", Type::Serial), ], ); - let diags = diag_keys( - "insert into Customers values (13, 'Oli', 42, 13)", - &schema, - ); + let diags = diag_keys("insert into Customers values (13, 'Oli', 42, 13)", &schema); assert!( !diags.iter().any(|d| d.contains("value(s) are given")), "matched Form B arity must not fire; got {diags:?}", @@ -5614,10 +5534,7 @@ mod tests { // (the schema-existence pass flags the unknown table, not the // arity pass). let schema = schema_with("Customers", &[("Name", Type::Text)]); - let diags = diag_keys( - "insert into Strangers values ('Oli', 52, 3)", - &schema, - ); + let diags = diag_keys("insert into Strangers values ('Oli', 52, 3)", &schema); assert!( !diags.iter().any(|d| d.contains("value(s) are given")), "unknown table must not trigger an arity diagnostic; got {diags:?}", @@ -5643,8 +5560,14 @@ mod tests { "insert into t (a, b) values (1, 2), (3, 4, 5), (6), (7, 8)", &schema, ); - let n = diags.iter().filter(|d| d.contains("value(s) are given")).count(); - assert_eq!(n, 2, "rows 2 and 3 mismatch, rows 1 and 4 match; got {diags:?}"); + let n = diags + .iter() + .filter(|d| d.contains("value(s) are given")) + .count(); + assert_eq!( + n, 2, + "rows 2 and 3 mismatch, rows 1 and 4 match; got {diags:?}" + ); } #[test] @@ -5657,8 +5580,14 @@ mod tests { "insert into t (a, b) values (1, 2, 3) on conflict (a) do nothing", &schema, ); - let n = diags.iter().filter(|d| d.contains("value(s) are given")).count(); - assert_eq!(n, 1, "only the 3-value tuple is flagged, not the conflict target; got {diags:?}"); + let n = diags + .iter() + .filter(|d| d.contains("value(s) are given")) + .count(); + assert_eq!( + n, 1, + "only the 3-value tuple is flagged, not the conflict target; got {diags:?}" + ); } #[test] @@ -5683,7 +5612,10 @@ mod tests { // RETURNING projection isn't mis-counted as a value tuple. let schema = schema_with("t", &[("a", Type::Int), ("b", Type::Int)]); let diags = diag_keys("insert into t (a, b) values (1, 2, 3) returning *", &schema); - let n = diags.iter().filter(|d| d.contains("value(s) are given")).count(); + let n = diags + .iter() + .filter(|d| d.contains("value(s) are given")) + .count(); assert_eq!(n, 1, "only the 3-value tuple is flagged; got {diags:?}"); } @@ -5777,7 +5709,10 @@ mod tests { "insert into a (id, name) values (1, 'x') on conflict (id) do update set name = excluded.name", &schema, ); - assert!(diags.is_empty(), "excluded.name should resolve in DO UPDATE; got {diags:?}"); + assert!( + diags.is_empty(), + "excluded.name should resolve in DO UPDATE; got {diags:?}" + ); } #[test] @@ -5789,7 +5724,10 @@ mod tests { "insert into a (id, name) values (1, 'x') on conflict (id) do update set name = 'y' where id = excluded.id", &schema, ); - assert!(diags.is_empty(), "excluded in DO UPDATE WHERE should resolve; got {diags:?}"); + assert!( + diags.is_empty(), + "excluded in DO UPDATE WHERE should resolve; got {diags:?}" + ); } #[test] @@ -5896,10 +5834,7 @@ mod tests { // the DISTINCT keyword doesn't shield the column-ref // validation downstream. let schema = schema_with("Customers", &[("Age", Type::Int)]); - let diags = diag_keys( - "select count(distinct nonexistent) from Customers", - &schema, - ); + let diags = diag_keys("select count(distinct nonexistent) from Customers", &schema); assert!( diags.iter().any(|d| d.contains("no such column")), "unknown column inside `count(distinct …)` must still fire; got {diags:?}", @@ -5913,10 +5848,7 @@ mod tests { // ordinary expressions and a genuinely unknown column inside // a call must still fire `no such column`. let schema = schema_with("Customers", &[("Age", Type::Int)]); - let diags = diag_keys( - "select sum(nonexistent) from Customers", - &schema, - ); + let diags = diag_keys("select sum(nonexistent) from Customers", &schema); assert!( diags.iter().any(|d| d.contains("no such column")), "unknown column inside the call args must still fire; got {diags:?}", @@ -5930,10 +5862,7 @@ mod tests { // embedded in an INSERT (no re-implementation needed). // `nonexistent_col` is not a column of `a`. let schema = two_table_schema(); - let diags = diag_keys( - "insert into b select nonexistent_col from a", - &schema, - ); + let diags = diag_keys("insert into b select nonexistent_col from a", &schema); assert!( diags.iter().any(|d| d.contains("no such column")), "expected unknown_column on the INSERT…SELECT projection; \ @@ -6027,7 +5956,10 @@ mod tests { // INSERT VALUES carries no row scope, so the realizable // claim is the INSERT … SELECT slot, not VALUES. let schema = two_table_schema(); // a(id,name), b(id,total real) - let diags = diag_keys("insert into a (id) select id from b where total like 5", &schema); + let diags = diag_keys( + "insert into a (id) select id from b where total like 5", + &schema, + ); assert!( diags.iter().any(|d| d.contains("LIKE")), "expected like_numeric on the INSERT…SELECT WHERE; got {diags:?}", @@ -6043,10 +5975,7 @@ mod tests { "with cte_x as (select * from base) select * from cte_x", &schema, ); - assert!( - diags.is_empty(), - "expected no diagnostics; got {diags:?}", - ); + assert!(diags.is_empty(), "expected no diagnostics; got {diags:?}",); } #[test] @@ -6060,8 +5989,7 @@ mod tests { let mut ctx = super::context::WalkContext::with_schema(&schema); let mut path = super::outcome::MatchedPath::new(); let mut per_byte: Vec = Vec::new(); - let input = - "with x as (select 1), x as (select 2) select * from x"; + let input = "with x as (select 1), x as (select 2) select * from x"; let result = crate::dsl::walker::driver::walk_node( input, 0, @@ -6078,8 +6006,7 @@ mod tests { "fragment should walk: {result:?}" ); let diags = super::schema_existence_diagnostics(&path, Some(&schema)); - let messages: Vec<&str> = - diags.iter().map(|d| d.message.as_str()).collect(); + let messages: Vec<&str> = diags.iter().map(|d| d.message.as_str()).collect(); assert!( messages.iter().any(|m| m.contains("duplicate")), "expected duplicate_cte; got {messages:?}", @@ -6135,10 +6062,7 @@ mod tests { let schema = two_table_schema(); // The alias `x` resolves to `a` — `x.name` finds `a.name`. let diags = diag_keys("select x.name from a x", &schema); - assert!( - diags.is_empty(), - "expected no diagnostics; got {diags:?}", - ); + assert!(diags.is_empty(), "expected no diagnostics; got {diags:?}",); } // ---- ADR-0032 §11.6 — Phase-1 carry-over gap closure ---- @@ -6166,8 +6090,7 @@ mod tests { // Phase 1's predicate-warning pass walked the DSL Expr // AST and never saw SQL WHERE. let schema = typed_schema(); - let diags = - diag_keys("select * from products where price like 5", &schema); + let diags = diag_keys("select * from products where price like 5", &schema); assert!( diags.iter().any(|d| d.contains("LIKE")), "expected like_numeric warning on SQL WHERE; got {diags:?}", @@ -6177,8 +6100,7 @@ mod tests { #[test] fn sql_where_eq_null_warns() { let schema = typed_schema(); - let diags = - diag_keys("select * from products where name = null", &schema); + let diags = diag_keys("select * from products where name = null", &schema); assert!( diags.iter().any(|d| d.contains("= NULL")), "expected eq_null warning on SQL WHERE; got {diags:?}", @@ -6188,8 +6110,7 @@ mod tests { #[test] fn sql_where_type_mismatch_text_vs_number_warns() { let schema = typed_schema(); - let diags = - diag_keys("select * from products where name = 5", &schema); + let diags = diag_keys("select * from products where name = 5", &schema); assert!( diags.iter().any(|d| d.contains("different type")), "expected type_mismatch warning on SQL WHERE; got {diags:?}", @@ -6199,10 +6120,7 @@ mod tests { #[test] fn sql_where_type_mismatch_number_vs_text_warns() { let schema = typed_schema(); - let diags = diag_keys( - "select * from products where price = 'high'", - &schema, - ); + let diags = diag_keys("select * from products where price = 'high'", &schema); assert!( diags.iter().any(|d| d.contains("different type")), "expected type_mismatch warning on SQL WHERE; got {diags:?}", @@ -6212,8 +6130,7 @@ mod tests { #[test] fn sql_where_type_compatible_does_not_warn() { let schema = typed_schema(); - let diags = - diag_keys("select * from products where price = 5", &schema); + let diags = diag_keys("select * from products where price = 5", &schema); // `price` is real; `5` is numeric — compatible (any // numeric-real comparison is fine). No warning. assert!( @@ -6258,10 +6175,7 @@ mod tests { // Predicate-shape inside ORDER BY's sql_expr — same // pass, same warning. let schema = typed_schema(); - let diags = diag_keys( - "select * from products order by price like 5", - &schema, - ); + let diags = diag_keys("select * from products order by price like 5", &schema); assert!( diags.iter().any(|d| d.contains("LIKE")), "expected like_numeric warning inside ORDER BY; got {diags:?}", @@ -6273,10 +6187,7 @@ mod tests { // Predicate shape used as a projection item (returns // 0/1). Same warning surface. let schema = typed_schema(); - let diags = diag_keys( - "select price like 5 from products", - &schema, - ); + let diags = diag_keys("select price like 5 from products", &schema); assert!( diags.iter().any(|d| d.contains("LIKE")), "expected like_numeric warning inside projection; got {diags:?}", @@ -6293,16 +6204,23 @@ mod tests { cache.columns.push("price".to_string()); cache.table_columns.insert( "a".to_string(), - vec![TableColumn { name: "id".to_string(), user_type: Type::Int, not_null: false, has_default: false }], + vec![TableColumn { + name: "id".to_string(), + user_type: Type::Int, + not_null: false, + has_default: false, + }], ); cache.table_columns.insert( "b".to_string(), - vec![TableColumn { name: "price".to_string(), user_type: Type::Real, not_null: false, has_default: false }], - ); - let diags = diag_keys( - "select * from a join b on price like 5", - &cache, + vec![TableColumn { + name: "price".to_string(), + user_type: Type::Real, + not_null: false, + has_default: false, + }], ); + let diags = diag_keys("select * from a join b on price like 5", &cache); assert!( diags.iter().any(|d| d.contains("LIKE")), "expected like_numeric warning on JOIN ON; got {diags:?}", @@ -6315,18 +6233,12 @@ mod tests { fn projection_alias_in_where_is_misplaced() { // ADR-0032 §11.2 plan test: `SELECT a + b AS x FROM t // WHERE x > 0` fires `projection_alias_misplaced`. - let schema = schema_with( - "t", - &[("a", Type::Int), ("b", Type::Int)], - ); - let diags = diag_keys( - "select a + b as x from t where x > 0", - &schema, - ); + let schema = schema_with("t", &[("a", Type::Int), ("b", Type::Int)]); + let diags = diag_keys("select a + b as x from t where x > 0", &schema); assert!( - diags.iter().any(|d| { - d.contains("alias `x`") && d.contains("WHERE") - }), + diags + .iter() + .any(|d| { d.contains("alias `x`") && d.contains("WHERE") }), "expected projection_alias_misplaced on WHERE; got {diags:?}", ); // The unknown_column diagnostic must NOT also fire on @@ -6339,36 +6251,24 @@ mod tests { #[test] fn projection_alias_in_having_is_misplaced() { - let schema = schema_with( - "t", - &[("a", Type::Int), ("b", Type::Int)], - ); - let diags = diag_keys( - "select a + b as x from t group by a having x > 0", - &schema, - ); + let schema = schema_with("t", &[("a", Type::Int), ("b", Type::Int)]); + let diags = diag_keys("select a + b as x from t group by a having x > 0", &schema); assert!( - diags.iter().any(|d| { - d.contains("alias `x`") && d.contains("HAVING") - }), + diags + .iter() + .any(|d| { d.contains("alias `x`") && d.contains("HAVING") }), "expected projection_alias_misplaced on HAVING; got {diags:?}", ); } #[test] fn projection_alias_in_group_by_is_misplaced() { - let schema = schema_with( - "t", - &[("a", Type::Int), ("b", Type::Int)], - ); - let diags = diag_keys( - "select a + b as x from t group by x", - &schema, - ); + let schema = schema_with("t", &[("a", Type::Int), ("b", Type::Int)]); + let diags = diag_keys("select a + b as x from t group by x", &schema); assert!( - diags.iter().any(|d| { - d.contains("alias `x`") && d.contains("GROUP BY") - }), + diags + .iter() + .any(|d| { d.contains("alias `x`") && d.contains("GROUP BY") }), "expected projection_alias_misplaced on GROUP BY; got {diags:?}", ); } @@ -6377,18 +6277,9 @@ mod tests { fn projection_alias_in_order_by_is_allowed() { // ADR-0032 §11.2 negative case: `… ORDER BY x` doesn't // fire — aliases are bound by ORDER BY evaluation time. - let schema = schema_with( - "t", - &[("a", Type::Int), ("b", Type::Int)], - ); - let diags = diag_keys( - "select a + b as x from t order by x", - &schema, - ); - assert!( - diags.is_empty(), - "ORDER BY alias is allowed; got {diags:?}", - ); + let schema = schema_with("t", &[("a", Type::Int), ("b", Type::Int)]); + let diags = diag_keys("select a + b as x from t order by x", &schema); + assert!(diags.is_empty(), "ORDER BY alias is allowed; got {diags:?}",); } #[test] @@ -6397,14 +6288,8 @@ mod tests { // `id` shadows nothing in the table, but a real column // `id` exists. WHERE id refers to the table column (per // SQL spec); the diagnostic must NOT fire. - let schema = schema_with( - "t", - &[("id", Type::Int), ("name", Type::Text)], - ); - let diags = diag_keys( - "select name as id from t where id > 0", - &schema, - ); + let schema = schema_with("t", &[("id", Type::Int), ("name", Type::Text)]); + let diags = diag_keys("select name as id from t where id > 0", &schema); assert!( diags.is_empty(), "real-column WHERE ref must not be flagged as misplaced; got {diags:?}", @@ -6440,8 +6325,7 @@ mod tests { // The same applies outside GROUP BY — a bare alias in the // projection (`SELECT o …`) is equally not a column. let schema = two_table_schema(); - let diags = - diag_keys("select o from a o join b on a.id = b.id", &schema); + let diags = diag_keys("select o from a o join b on a.id = b.id", &schema); assert!( diags.iter().any(|d| d.contains("`o` is a table alias")), "expected alias_used_as_column hint in projection; got {diags:?}", @@ -6472,7 +6356,9 @@ mod tests { &schema, ); assert!( - diags.iter().any(|d| d.contains("no such column") && d.contains("nope")), + diags + .iter() + .any(|d| d.contains("no such column") && d.contains("nope")), "a genuine unknown column must still report no such column; got {diags:?}", ); } @@ -6491,7 +6377,9 @@ mod tests { &schema, ); assert!( - diags.iter().any(|d| d.contains("no such column") && d.contains("`a`")), + diags + .iter() + .any(|d| d.contains("no such column") && d.contains("`a`")), "an aliased table referenced by its real name must fall through to \ unknown_column; got {diags:?}", ); @@ -6508,8 +6396,7 @@ mod tests { // has no `table.column` syntax, so advising the qualified form // would be wrong. A DSL bare table-name ref stays the generic // unknown_column it was before issue #31. - let schema = - schema_with("Customers", &[("id", Type::Int), ("Name", Type::Text)]); + let schema = schema_with("Customers", &[("id", Type::Int), ("Name", Type::Text)]); for input in [ "show data Customers where Customers = 5", "update Customers set Name = 'x' where Customers = 5", @@ -6535,10 +6422,9 @@ mod tests { let schema = schema_with("t", &[("a", Type::Int)]); let diags = diag_keys("select 1, 2 union select 1", &schema); assert!( - diags.iter().any(|d| { - d.contains("union") - && d.contains("number of columns") - }), + diags + .iter() + .any(|d| { d.contains("union") && d.contains("number of columns") }), "expected compound_arity_mismatch on UNION; got {diags:?}", ); } @@ -6557,13 +6443,11 @@ mod tests { #[test] fn compound_intersect_arity_mismatch_fires() { let schema = schema_with("t", &[("a", Type::Int)]); - let diags = - diag_keys("select 1 intersect select 1, 2", &schema); + let diags = diag_keys("select 1 intersect select 1, 2", &schema); assert!( - diags.iter().any(|d| { - d.contains("intersect") - && d.contains("number of columns") - }), + diags + .iter() + .any(|d| { d.contains("intersect") && d.contains("number of columns") }), "expected compound_arity_mismatch on INTERSECT; got {diags:?}", ); } @@ -6571,13 +6455,11 @@ mod tests { #[test] fn compound_except_arity_mismatch_fires() { let schema = schema_with("t", &[("a", Type::Int)]); - let diags = - diag_keys("select 1, 2, 3 except select 1, 2", &schema); + let diags = diag_keys("select 1, 2, 3 except select 1, 2", &schema); assert!( - diags.iter().any(|d| { - d.contains("except") - && d.contains("number of columns") - }), + diags + .iter() + .any(|d| { d.contains("except") && d.contains("number of columns") }), "expected compound_arity_mismatch on EXCEPT; got {diags:?}", ); } @@ -6587,14 +6469,8 @@ mod tests { // Function-call commas are at deeper depth — they must // not be counted as projection items. // `count(a, b)` is ONE projection item. - let schema = schema_with( - "t", - &[("a", Type::Int), ("b", Type::Int)], - ); - let diags = diag_keys( - "select count(a, b) from t union select 1", - &schema, - ); + let schema = schema_with("t", &[("a", Type::Int), ("b", Type::Int)]); + let diags = diag_keys("select count(a, b) from t union select 1", &schema); assert!( !diags.iter().any(|d| d.contains("number of columns")), "function-call commas must not inflate arity; got {diags:?}", @@ -6607,10 +6483,9 @@ mod tests { let schema = schema_with("t", &[("a", Type::Int)]); let diags = diag_keys("select 1 union all select 1, 2", &schema); assert!( - diags.iter().any(|d| { - d.contains("union") - && d.contains("number of columns") - }), + diags + .iter() + .any(|d| { d.contains("union") && d.contains("number of columns") }), "expected compound_arity_mismatch on UNION ALL; got {diags:?}", ); } @@ -6620,10 +6495,7 @@ mod tests { // Chained legs at the same depth — each set-op compares // its preceding leg against its following leg. let schema = schema_with("t", &[("a", Type::Int)]); - let diags = diag_keys( - "select 1 union select 1, 2 union select 1", - &schema, - ); + let diags = diag_keys("select 1 union select 1, 2 union select 1", &schema); let mismatch_count = diags .iter() .filter(|d| d.contains("number of columns")) @@ -6644,10 +6516,9 @@ mod tests { &schema, ); assert!( - diags.iter().any(|d| { - d.contains("union") - && d.contains("number of columns") - }), + diags + .iter() + .any(|d| { d.contains("union") && d.contains("number of columns") }), "expected compound_arity_mismatch inside CTE body; got {diags:?}", ); } @@ -6659,10 +6530,7 @@ mod tests { // `WITH x(a, b) AS (SELECT 1, 2, 3)` — declared 2, // derived 3 → fires. let schema = schema_with("base", &[("id", Type::Int)]); - let diags = diag_keys( - "with x (a, b) as (select 1, 2, 3) select * from x", - &schema, - ); + let diags = diag_keys("with x (a, b) as (select 1, 2, 3) select * from x", &schema); assert!( diags.iter().any(|d| { d.contains("CTE `x`") @@ -6678,10 +6546,7 @@ mod tests { // `WITH x(a, b, c) AS (SELECT 1)` — declared 3, // derived 1 → fires. let schema = schema_with("base", &[("id", Type::Int)]); - let diags = diag_keys( - "with x (a, b, c) as (select 1) select * from x", - &schema, - ); + let diags = diag_keys("with x (a, b, c) as (select 1) select * from x", &schema); assert!( diags.iter().any(|d| { d.contains("CTE `x`") @@ -6697,10 +6562,7 @@ mod tests { // `WITH x(a, b) AS (SELECT 1, 2)` — matched arity, no // diagnostic. let schema = schema_with("base", &[("id", Type::Int)]); - let diags = diag_keys( - "with x (a, b) as (select 1, 2) select * from x", - &schema, - ); + let diags = diag_keys("with x (a, b) as (select 1, 2) select * from x", &schema); assert!( !diags.iter().any(|d| d.contains("declares")), "matched arity should not fire; got {diags:?}", @@ -6712,10 +6574,7 @@ mod tests { // No explicit col-list → no arity check (derived // columns are the canonical view). let schema = schema_with("base", &[("id", Type::Int)]); - let diags = diag_keys( - "with x as (select 1, 2, 3) select * from x", - &schema, - ); + let diags = diag_keys("with x as (select 1, 2, 3) select * from x", &schema); assert!( !diags.iter().any(|d| d.contains("declares")), "no col-list should suppress arity check; got {diags:?}", @@ -6730,14 +6589,8 @@ mod tests { // — here we test that the outer `WHERE y` is flagged // as unknown_column (not misplaced) because there is no // alias `y` in the OUTER leg's projection. - let schema = schema_with( - "t", - &[("a", Type::Int), ("b", Type::Int)], - ); - let diags = diag_keys( - "select (select a as y from t) from t where y > 0", - &schema, - ); + let schema = schema_with("t", &[("a", Type::Int), ("b", Type::Int)]); + let diags = diag_keys("select (select a as y from t) from t where y > 0", &schema); assert!( !diags.iter().any(|d| d.contains("misplaced")), "inner-subquery alias must not affect outer scope; got {diags:?}", @@ -6795,14 +6648,10 @@ mod projection_before_from_tests { s } - fn diagnostics_advanced( - source: &str, - schema: &SchemaCache, - ) -> Vec { + fn diagnostics_advanced(source: &str, schema: &SchemaCache) -> Vec { let mut ctx = context::WalkContext::with_schema(schema); ctx.mode = crate::mode::Mode::Advanced; - let (result, _) = - walk(source, outcome::WalkBound::EndOfInput, &mut ctx); + let (result, _) = walk(source, outcome::WalkBound::EndOfInput, &mut ctx); result.map_or_else(Vec::new, |r| r.diagnostics) } @@ -6813,8 +6662,7 @@ mod projection_before_from_tests { // but the two-pass diagnostic resolves correctly // against the eventual scope. No diagnostic. let schema = schema_with_table_and_columns(); - let diags = - diagnostics_advanced("select real_col from mytable", &schema); + let diags = diagnostics_advanced("select real_col from mytable", &schema); assert!( diags.is_empty(), "projection-before-FROM legit column must not be flagged; got {diags:?}", @@ -6828,8 +6676,7 @@ mod projection_before_from_tests { // projection ident's span; the renderer overlays this // as Error in `input_render.rs`. let schema = schema_with_table_and_columns(); - let diags = - diagnostics_advanced("select bogus_col from mytable", &schema); + let diags = diagnostics_advanced("select bogus_col from mytable", &schema); assert_eq!(diags.len(), 1, "{diags:?}"); assert_eq!(diags[0].severity, outcome::Severity::Error); // Span should cover `bogus_col` (offset 7..16). @@ -6867,10 +6714,7 @@ mod projection_before_from_tests { // schema_existence pass. let schema = schema_with_table_and_columns(); let diags = diagnostics_advanced("select c1, c2", &schema); - assert!( - diags.is_empty(), - "no FROM → silent; got {diags:?}", - ); + assert!(diags.is_empty(), "no FROM → silent; got {diags:?}",); } } @@ -6889,9 +6733,7 @@ mod projection_before_from_tests { mod dispatch_3a_tests { use super::*; use crate::dsl::command::{AppCommand, Command}; - use crate::dsl::grammar::{ - CommandCategory, CommandNode, Node, ValidationError, Word, - }; + use crate::dsl::grammar::{CommandCategory, CommandNode, Node, ValidationError, Word}; use crate::dsl::walker::lex_helpers::{consume_ident, skip_whitespace}; use crate::dsl::walker::outcome::MatchedPath; use crate::mode::Mode; @@ -6962,9 +6804,10 @@ mod dispatch_3a_tests { let (res, cmd) = walk_one_command(input, input, ks, ke, idx, node, &mut ctx); (res.outcome, cmd) } - Decision::ThisIsSql { primary } => { - (this_is_sql_result(entry_text, primary, ks, ke).outcome, None) - } + Decision::ThisIsSql { primary } => ( + this_is_sql_result(entry_text, primary, ks, ke).outcome, + None, + ), } } @@ -6981,11 +6824,17 @@ mod dispatch_3a_tests { fn simple_mode_dsl_input_matches_dsl() { let cands = shared(); assert!( - std::ptr::eq(committed_node("smk dsltail", Mode::Simple, &cands), &SMOKE_DSL), + std::ptr::eq( + committed_node("smk dsltail", Mode::Simple, &cands), + &SMOKE_DSL + ), "simple mode must commit the DSL node for DSL input", ); let (outcome, cmd) = dispatch("smk dsltail", Mode::Simple, &cands); - assert!(matches!(outcome, WalkOutcome::Match { .. }), "got {outcome:?}"); + assert!( + matches!(outcome, WalkOutcome::Match { .. }), + "got {outcome:?}" + ); assert_eq!(cmd, Some(Command::App(AppCommand::Help { topic: None }))); } @@ -6995,11 +6844,17 @@ mod dispatch_3a_tests { fn advanced_mode_sql_input_matches_sql() { let cands = shared(); assert!( - std::ptr::eq(committed_node("smk sqltail", Mode::Advanced, &cands), &SMOKE_SQL), + std::ptr::eq( + committed_node("smk sqltail", Mode::Advanced, &cands), + &SMOKE_SQL + ), "advanced mode must commit the SQL node for SQL input", ); let (outcome, cmd) = dispatch("smk sqltail", Mode::Advanced, &cands); - assert!(matches!(outcome, WalkOutcome::Match { .. }), "got {outcome:?}"); + assert!( + matches!(outcome, WalkOutcome::Match { .. }), + "got {outcome:?}" + ); assert_eq!(cmd, Some(Command::App(AppCommand::Quit))); } @@ -7020,7 +6875,10 @@ mod dispatch_3a_tests { // form (see `simple_mode_sql_only_entry_word_is_this_is_sql`). let cands = shared(); assert!( - std::ptr::eq(committed_node("smk sqltail", Mode::Simple, &cands), &SMOKE_DSL), + std::ptr::eq( + committed_node("smk sqltail", Mode::Simple, &cands), + &SMOKE_DSL + ), "simple mode must commit the DSL node for a shared word", ); let (outcome, cmd) = dispatch("smk sqltail", Mode::Simple, &cands); @@ -7057,11 +6915,17 @@ mod dispatch_3a_tests { // DSL node rather than surfacing the SQL node's failure. let cands = shared(); assert!( - std::ptr::eq(committed_node("smk dsltail", Mode::Advanced, &cands), &SMOKE_DSL), + std::ptr::eq( + committed_node("smk dsltail", Mode::Advanced, &cands), + &SMOKE_DSL + ), "advanced mode must fall back to DSL when SQL doesn't match", ); let (outcome, cmd) = dispatch("smk dsltail", Mode::Advanced, &cands); - assert!(matches!(outcome, WalkOutcome::Match { .. }), "got {outcome:?}"); + assert!( + matches!(outcome, WalkOutcome::Match { .. }), + "got {outcome:?}" + ); assert_eq!(cmd, Some(Command::App(AppCommand::Help { topic: None }))); } @@ -7075,7 +6939,10 @@ mod dispatch_3a_tests { &SMOKE_DSL, )); let (outcome, _) = dispatch("smk dsltail", Mode::Advanced, &cands); - assert!(matches!(outcome, WalkOutcome::Match { .. }), "got {outcome:?}"); + assert!( + matches!(outcome, WalkOutcome::Match { .. }), + "got {outcome:?}" + ); } } @@ -7106,8 +6973,19 @@ mod order_by_expected_set_tests { fn order_by_excludes_preceding_clause_keywords() { let words = expected_words("select Name from T order by "); let preceding_clause_kw = [ - "where", "group", "having", "join", "union", "intersect", - "except", "left", "right", "full", "cross", "inner", "as", + "where", + "group", + "having", + "join", + "union", + "intersect", + "except", + "left", + "right", + "full", + "cross", + "inner", + "as", ]; let leaked: Vec<&str> = preceding_clause_kw .iter() @@ -7131,10 +7009,7 @@ mod order_by_expected_set_tests { assert!(words.contains(&"asc"), "expected `asc`; got {words:?}"); assert!(words.contains(&"desc"), "expected `desc`; got {words:?}"); // The separator is deliberately not surfaced (user choice). - let full = expected_at_input_in_mode( - "select Name from T order by Name ", - Mode::Advanced, - ); + let full = expected_at_input_in_mode("select Name from T order by Name ", Mode::Advanced); assert!( !full.iter().any(|e| matches!(e, Expectation::Punct(','))), "`,` separator should not be surfaced; got {full:?}", @@ -7146,15 +7021,11 @@ mod order_by_expected_set_tests { // Guard against over-correction: the legitimate sort-item // continuation (a column identifier) must survive the // pending-skipped suppression. - let expected = expected_at_input_in_mode( - "select Name from T order by ", - Mode::Advanced, - ); + let expected = expected_at_input_in_mode("select Name from T order by ", Mode::Advanced); assert!( - expected.iter().any(|e| matches!( - e, - Expectation::Ident { .. } | Expectation::NumberLit - )), + expected + .iter() + .any(|e| matches!(e, Expectation::Ident { .. } | Expectation::NumberLit)), "ORDER BY must still offer a sort item; got {expected:?}", ); } diff --git a/src/echo.rs b/src/echo.rs index 04461ca..1eceaf7 100644 --- a/src/echo.rs +++ b/src/echo.rs @@ -14,12 +14,12 @@ //! advanced effective mode (ADR-0037). use crate::app::EffectiveMode; -use crate::dsl::ReferentialAction; -use crate::dsl::types::Type; use crate::dsl::Command; +use crate::dsl::ReferentialAction; use crate::dsl::command::{ ColumnSpec, CompareOp, Constraint, ConstraintKind, Expr, Operand, Predicate, RowFilter, }; +use crate::dsl::types::Type; use crate::dsl::value::Value; /// The dimmed `Executing SQL:` prefix on a teaching-echo line @@ -79,7 +79,12 @@ pub fn echo_for_query( name, filter, limit, - } => Some(vec![render_show_data(name, filter.as_ref(), *limit, primary_key)]), + } => Some(vec![render_show_data( + name, + filter.as_ref(), + *limit, + primary_key, + )]), _ => None, } } @@ -150,12 +155,12 @@ pub fn command_to_sql(command: &Command) -> Option { column, kind, } => match kind { - ConstraintKind::NotNull => { - Some(format!("ALTER TABLE {table} ALTER COLUMN {column} DROP NOT NULL")) - } - ConstraintKind::Default => { - Some(format!("ALTER TABLE {table} ALTER COLUMN {column} DROP DEFAULT")) - } + ConstraintKind::NotNull => Some(format!( + "ALTER TABLE {table} ALTER COLUMN {column} DROP NOT NULL" + )), + ConstraintKind::Default => Some(format!( + "ALTER TABLE {table} ALTER COLUMN {column} DROP DEFAULT" + )), // A column-level UNIQUE / CHECK is anonymous in our model — // no portable name to DROP CONSTRAINT by, so no echo (Bucket C, // ADR-0035 Amendment 2 residual gap / ADR-0038 §7). @@ -169,7 +174,10 @@ pub fn command_to_sql(command: &Command) -> Option { table, assignments, filter: RowFilter::AllRows, - } => Some(format!("UPDATE {table} SET {}", render_assignments(assignments))), + } => Some(format!( + "UPDATE {table} SET {}", + render_assignments(assignments) + )), Command::Delete { table, filter: RowFilter::AllRows, @@ -199,7 +207,13 @@ fn render_create_table(name: &str, columns: &[ColumnSpec], primary_key: &[String // The same column-constraint suffix `add column` emits (ADR-0029): // simple-mode `create table` can carry `default` / `check` too, so // the echo must render them or it is not equivalent (§1 contract). - append_constraints(&mut s, c.not_null, c.unique, c.default.as_ref(), c.check.as_ref()); + append_constraints( + &mut s, + c.not_null, + c.unique, + c.default.as_ref(), + c.check.as_ref(), + ); s }) .collect(); @@ -299,8 +313,10 @@ pub(crate) fn render_create_m2n( primary_key: &[String], foreign_keys: &[(Vec, String, Vec)], ) -> String { - let mut parts: Vec = - columns.iter().map(|(n, ty)| format!("{n} {}", ty.keyword())).collect(); + let mut parts: Vec = columns + .iter() + .map(|(n, ty)| format!("{n} {}", ty.keyword())) + .collect(); parts.push(format!("PRIMARY KEY ({})", primary_key.join(", "))); for (child_columns, parent_table, parent_columns) in foreign_keys { parts.push(format!( @@ -368,7 +384,12 @@ pub(crate) fn render_add_relationship_create_fk( ) -> Vec { let mut lines: Vec = new_columns .iter() - .map(|(col, ty)| format!("ALTER TABLE {child_table} ADD COLUMN {col} {}", ty.keyword())) + .map(|(col, ty)| { + format!( + "ALTER TABLE {child_table} ADD COLUMN {col} {}", + ty.keyword() + ) + }) .collect(); lines.push(render_add_relationship( name, @@ -461,7 +482,11 @@ fn predicate_to_sql(predicate: &Predicate) -> String { negated, } => { let not = if *negated { "NOT " } else { "" }; - format!("{} {not}LIKE {}", operand_to_sql(target), operand_to_sql(pattern)) + format!( + "{} {not}LIKE {}", + operand_to_sql(target), + operand_to_sql(pattern) + ) } Predicate::Between { target, @@ -484,7 +509,11 @@ fn predicate_to_sql(predicate: &Predicate) -> String { } => { let not = if *negated { "NOT " } else { "" }; let rendered: Vec = items.iter().map(operand_to_sql).collect(); - format!("{} {not}IN ({})", operand_to_sql(target), rendered.join(", ")) + format!( + "{} {not}IN ({})", + operand_to_sql(target), + rendered.join(", ") + ) } Predicate::IsNull { target, negated } => { let not = if *negated { "NOT " } else { "" }; @@ -562,7 +591,10 @@ mod tests { fn create_table_compound_pk_renders_table_level() { let cmd = create_table( "T", - vec![ColumnSpec::new("a", Type::Int), ColumnSpec::new("b", Type::Int)], + vec![ + ColumnSpec::new("a", Type::Int), + ColumnSpec::new("b", Type::Int), + ], &["a", "b"], ); assert_eq!( @@ -594,7 +626,11 @@ mod tests { default: Some(Value::Text("A".to_string())), ..ColumnSpec::new("grade", Type::Text) }; - let cmd = create_table("T", vec![ColumnSpec::new("id", Type::Serial), age, grade], &["id"]); + let cmd = create_table( + "T", + vec![ColumnSpec::new("id", Type::Serial), age, grade], + &["id"], + ); let sql = command_to_sql(&cmd).expect("echo"); assert_eq!( sql, @@ -625,11 +661,11 @@ mod tests { check: None, }; let sql = command_to_sql(&cmd).expect("echo"); - assert_eq!(sql, "ALTER TABLE T ADD COLUMN note text NOT NULL DEFAULT 'n/a'"); - assert!(matches!( - reparse(&sql), - Ok(Command::SqlAlterTable { .. }) - )); + assert_eq!( + sql, + "ALTER TABLE T ADD COLUMN note text NOT NULL DEFAULT 'n/a'" + ); + assert!(matches!(reparse(&sql), Ok(Command::SqlAlterTable { .. }))); } #[test] @@ -657,7 +693,10 @@ mod tests { })), }; let sql = command_to_sql(&cmd).expect("echo"); - assert_eq!(sql, "ALTER TABLE T ADD COLUMN score int UNIQUE CHECK (score >= 0)"); + assert_eq!( + sql, + "ALTER TABLE T ADD COLUMN score int UNIQUE CHECK (score >= 0)" + ); assert!(matches!(reparse(&sql), Ok(Command::SqlAlterTable { .. }))); } @@ -1031,7 +1070,10 @@ mod tests { let lines = render_drop_column_cascade( "Orders", "CustId", - &["Orders_CustId_idx".to_string(), "Orders_CustId_Day_idx".to_string()], + &[ + "Orders_CustId_idx".to_string(), + "Orders_CustId_Day_idx".to_string(), + ], ); assert_eq!( lines.as_slice(), @@ -1043,9 +1085,18 @@ mod tests { ); // Each line is itself runnable advanced-mode SQL (the §1 contract // holds per line for category 2). - assert!(matches!(reparse(&lines[0]), Ok(Command::SqlDropIndex { .. }))); - assert!(matches!(reparse(&lines[1]), Ok(Command::SqlDropIndex { .. }))); - assert!(matches!(reparse(&lines[2]), Ok(Command::SqlAlterTable { .. }))); + assert!(matches!( + reparse(&lines[0]), + Ok(Command::SqlDropIndex { .. }) + )); + assert!(matches!( + reparse(&lines[1]), + Ok(Command::SqlDropIndex { .. }) + )); + assert!(matches!( + reparse(&lines[2]), + Ok(Command::SqlAlterTable { .. }) + )); } #[test] @@ -1054,7 +1105,10 @@ mod tests { // plain `DROP COLUMN` — still semantically equivalent. let lines = render_drop_column_cascade("T", "c", &[]); assert_eq!(lines.as_slice(), &["ALTER TABLE T DROP COLUMN c"]); - assert!(matches!(reparse(&lines[0]), Ok(Command::SqlAlterTable { .. }))); + assert!(matches!( + reparse(&lines[0]), + Ok(Command::SqlAlterTable { .. }) + )); } #[test] @@ -1078,8 +1132,14 @@ mod tests { "ALTER TABLE Orders ADD CONSTRAINT Customers_id_to_Orders_CustId FOREIGN KEY (CustId) REFERENCES Customers (id) ON DELETE CASCADE", ] ); - assert!(matches!(reparse(&lines[0]), Ok(Command::SqlAlterTable { .. }))); - assert!(matches!(reparse(&lines[1]), Ok(Command::SqlAlterTable { .. }))); + assert!(matches!( + reparse(&lines[0]), + Ok(Command::SqlAlterTable { .. }) + )); + assert!(matches!( + reparse(&lines[1]), + Ok(Command::SqlAlterTable { .. }) + )); } #[test] @@ -1116,8 +1176,16 @@ mod tests { ], &["Students_id".to_string(), "Courses_id".to_string()], &[ - (vec!["Students_id".to_string()], "Students".to_string(), vec!["id".to_string()]), - (vec!["Courses_id".to_string()], "Courses".to_string(), vec!["id".to_string()]), + ( + vec!["Students_id".to_string()], + "Students".to_string(), + vec!["id".to_string()], + ), + ( + vec!["Courses_id".to_string()], + "Courses".to_string(), + vec!["id".to_string()], + ), ], ); assert_eq!( @@ -1172,8 +1240,14 @@ mod tests { #[test] fn value_literal_renders_null_uppercase_and_quotes_text() { assert_eq!(value_to_sql_literal(&Value::Null), "NULL"); - assert_eq!(value_to_sql_literal(&Value::Text("O'Hara".to_string())), "'O''Hara'"); - assert_eq!(value_to_sql_literal(&Value::Number("3.14".to_string())), "3.14"); + assert_eq!( + value_to_sql_literal(&Value::Text("O'Hara".to_string())), + "'O''Hara'" + ); + assert_eq!( + value_to_sql_literal(&Value::Number("3.14".to_string())), + "3.14" + ); assert_eq!(value_to_sql_literal(&Value::Bool(false)), "false"); } @@ -1258,9 +1332,7 @@ mod tests { "Command::App({app:?}) is Bucket C — no echo" ); // Also confirm echo_for gates the same in advanced mode. - assert!( - echo_for(&Command::App(app), EffectiveMode::AdvancedPersistent).is_none(), - ); + assert!(echo_for(&Command::App(app), EffectiveMode::AdvancedPersistent).is_none(),); } } diff --git a/src/event.rs b/src/event.rs index b71b850..d30b360 100644 --- a/src/event.rs +++ b/src/event.rs @@ -8,9 +8,8 @@ use crossterm::event::KeyEvent; use crate::db::{ - AddColumnResult, ChangeColumnTypeResult, DataResult, DbError, DeleteResult, - DropColumnResult, InsertResult, QueryPlan, RelationshipDiagramData, TableDescription, - UpdateResult, + AddColumnResult, ChangeColumnTypeResult, DataResult, DbError, DeleteResult, DropColumnResult, + InsertResult, QueryPlan, RelationshipDiagramData, TableDescription, UpdateResult, }; use crate::dsl::Command; @@ -73,10 +72,16 @@ pub enum AppEvent { }, /// An `explain …` command succeeded (ADR-0028). `plan` /// carries the captured query plan; nothing was executed. - DslExplainSucceeded { command: Command, plan: QueryPlan }, + DslExplainSucceeded { + command: Command, + plan: QueryPlan, + }, /// A `show ` list command (V5) — carries pre-formatted /// display lines (tables / relationships / indexes). - DslShowListSucceeded { command: Command, lines: Vec }, + DslShowListSucceeded { + command: Command, + lines: Vec, + }, /// `show relationship ` (ADR-0044) — structured data for the /// diagram, rendered App-side; `None` when no such relationship. DslShowRelationshipSucceeded { diff --git a/src/friendly/format.rs b/src/friendly/format.rs index 7e7192b..404166d 100644 --- a/src/friendly/format.rs +++ b/src/friendly/format.rs @@ -43,17 +43,11 @@ impl Catalog { } } -fn flatten( - value: &serde_norway::Value, - prefix: String, - out: &mut HashMap, -) { +fn flatten(value: &serde_norway::Value, prefix: String, out: &mut HashMap) { match value { serde_norway::Value::Mapping(map) => { for (k, v) in map { - let k_str = k - .as_str() - .expect("catalog keys must be strings"); + let k_str = k.as_str().expect("catalog keys must be strings"); let next = if prefix.is_empty() { k_str.to_string() } else { @@ -85,9 +79,7 @@ pub fn catalog() -> &'static Catalog { /// See module docs for failure modes. pub fn translate(key: &str, args: &[(&str, &dyn Display)]) -> String { let template = catalog().get(key).unwrap_or_else(|| { - panic!( - "missing catalog key: `{key}` (the validator should have caught this)" - ); + panic!("missing catalog key: `{key}` (the validator should have caught this)"); }); substitute(template, args, key) } diff --git a/src/friendly/keys.rs b/src/friendly/keys.rs index bd40341..21af9f3 100644 --- a/src/friendly/keys.rs +++ b/src/friendly/keys.rs @@ -41,8 +41,14 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[ ("diagnostic.alias_used_as_column", &["name"]), ("diagnostic.ambiguous_column", &["column", "qualifiers"]), ("diagnostic.auto_column_overridden", &["column", "type"]), - ("diagnostic.compound_arity_mismatch", &["op", "left_n", "right_n"]), - ("diagnostic.cte_arity_mismatch", &["cte", "declared", "actual"]), + ( + "diagnostic.compound_arity_mismatch", + &["op", "left_n", "right_n"], + ), + ( + "diagnostic.cte_arity_mismatch", + &["cte", "declared", "actual"], + ), ("diagnostic.duplicate_cte", &["name"]), ("diagnostic.eq_null", &[]), ("diagnostic.insert_arity_mismatch", &["expected", "actual"]), @@ -63,7 +69,10 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[ ), ("diagnostic.not_null_missing", &["column"]), ("diagnostic.like_numeric", &["column", "type"]), - ("diagnostic.projection_alias_misplaced", &["alias", "clause"]), + ( + "diagnostic.projection_alias_misplaced", + &["alias", "clause"], + ), ("diagnostic.table_used_as_column", &["name"]), ("diagnostic.type_mismatch", &["column", "type"]), ("diagnostic.unknown_column", &["name", "table"]), @@ -149,10 +158,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[ "error.type_mismatch.change_column.headline", &["table", "column", "src_type", "target_type"], ), - ( - "error.type_mismatch.change_column.hint", - &["target_type"], - ), + ("error.type_mismatch.change_column.hint", &["target_type"]), ( "error.type_mismatch.insert.headline", &["value", "expected_type"], @@ -219,10 +225,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[ ("help.data.explain", &[]), // ---- Hint panel ambient typing assistance (ADR-0022 §6) ---- ("hint.ambient_complete", &[]), - ( - "hint.ambient_error_with_usage", - &["message", "usage"], - ), + ("hint.ambient_error_with_usage", &["message", "usage"]), ("hint.ambient_expected", &["expected"]), ("hint.getting_started", &[]), ("hint.block.heading", &[]), @@ -404,10 +407,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[ ("hint.cmd.explain_sql.what", &[]), ("hint.cmd.explain_sql.example", &[]), ("hint.cmd.explain_sql.concept", &[]), - ( - "hint.ambient_invalid_ident", - &["kind", "found"], - ), + ("hint.ambient_invalid_ident", &["kind", "found"]), ("hint.ambient_typing_name", &[]), // Issue #4: introduce the advanced-mode CREATE TABLE element // slot (`create table T (`) so the otherwise-invisible @@ -415,10 +415,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[ ("hint.create_table_element", &[]), ("hint.seed_count", &[]), ("hint.value_literal_slot", &[]), - ( - "hint.ambient_typing_name_then", - &["next"], - ), + ("hint.ambient_typing_name_then", &["next"]), // Per-column-type value-slot hints (ADR-0024 §Phase D). ("hint.value_slot_blob", &[]), ("hint.value_slot_bool", &[]), @@ -441,7 +438,10 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[ ("parse.custom.alter_named_unique", &[]), ("parse.custom.bind_type_mismatch", &["found", "expected"]), ("parse.custom.change_column_flags_exclusive", &[]), - ("parse.custom.constraint_redundant_on_pk", &["column", "constraint"]), + ( + "parse.custom.constraint_redundant_on_pk", + &["column", "constraint"], + ), ("parse.custom.create_table_needs_pk", &[]), ("parse.custom.expression_too_deep", &[]), ("parse.custom.insert_form_a_missing_values", &["columns"]), @@ -576,10 +576,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[ &["table", "col_count", "col_list", "supplied", "non_auto_csv"], ), ("select.internal_table", &["table"]), - ( - "cli.invalid_value", - &["flag", "value", "expected"], - ), + ("cli.invalid_value", &["flag", "value", "expected"]), ("cli.missing_value", &["flag"]), ("cli.multiple_paths", &["first", "second"]), ("cli.resume_with_path", &[]), @@ -867,8 +864,7 @@ mod tests { } } - let declared: HashSet<&str> = - KEYS_AND_PLACEHOLDERS.iter().map(|(k, _)| *k).collect(); + let declared: HashSet<&str> = KEYS_AND_PLACEHOLDERS.iter().map(|(k, _)| *k).collect(); for key in cat.keys() { if key.starts_with("_test.") { continue; @@ -890,9 +886,8 @@ mod tests { /// Mirror of `tests/engine_vocabulary_audit.rs::FORBIDDEN`, /// duplicated here so the catalog validator is self-contained /// (no dependency on the integration-test binary). - const FORBIDDEN_ENGINE_VOCABULARY: &[&str] = &[ - "SQLite", "sqlite", "rusqlite", "STRICT", "PRAGMA", - ]; + const FORBIDDEN_ENGINE_VOCABULARY: &[&str] = + &["SQLite", "sqlite", "rusqlite", "STRICT", "PRAGMA"]; /// Detect a `{name:...}` format-specifier placeholder. /// Doubled braces `{{` / `}}` are escapes — must skip them. diff --git a/src/friendly/mod.rs b/src/friendly/mod.rs index 4c571f6..5cfa58f 100644 --- a/src/friendly/mod.rs +++ b/src/friendly/mod.rs @@ -34,8 +34,8 @@ pub mod keys; pub mod translate; pub use error::{DiagnosticTable, FriendlyError}; -pub use format::{catalog, Catalog}; -pub use translate::{error_hint_class, FailureContext, Operation, TranslateContext, Verbosity}; +pub use format::{Catalog, catalog}; +pub use translate::{FailureContext, Operation, TranslateContext, Verbosity, error_hint_class}; // `translate::translate` and `format::translate` are different // callables — the former is the structured DbError → FriendlyError diff --git a/src/friendly/translate.rs b/src/friendly/translate.rs index aecf487..b52dc7c 100644 --- a/src/friendly/translate.rs +++ b/src/friendly/translate.rs @@ -201,11 +201,7 @@ impl TranslateContext { /// Combine schema-resolved facts with operation and /// verbosity to build the full translator input. #[must_use] - pub fn from_facts( - operation: Operation, - verbosity: Verbosity, - facts: FailureContext, - ) -> Self { + pub fn from_facts(operation: Operation, verbosity: Verbosity, facts: FailureContext) -> Self { Self { operation: Some(operation), table: facts.table, @@ -234,15 +230,15 @@ pub fn translate(error: &DbError, ctx: &TranslateContext) -> FriendlyError { // refusal sites). Catalog entries exist for the typed // invalid-value cases but the migration sweep // (ADR-0019 §9) is what wires them. For now, passthrough. - DbError::Unsupported(message) | DbError::InvalidValue(message) => { - passthrough(message) - } + DbError::Unsupported(message) | DbError::InvalidValue(message) => passthrough(message), DbError::PersistenceFatal { message, .. } - | DbError::RebuildRowFailed { detail: message, .. } + | DbError::RebuildRowFailed { + detail: message, .. + } | DbError::Io(message) => passthrough(message), - DbError::WorkerGone => passthrough( - "the database worker is no longer available — the application must restart", - ), + DbError::WorkerGone => { + passthrough("the database worker is no longer available — the application must restart") + } }; // Attach the row pinpoint when the runtime resolved one. // The translator never builds the table itself — it only @@ -320,11 +316,7 @@ const fn fk_hint_class(ctx: &TranslateContext) -> &'static str { } } -fn translate_sqlite( - message: &str, - kind: SqliteErrorKind, - ctx: &TranslateContext, -) -> FriendlyError { +fn translate_sqlite(message: &str, kind: SqliteErrorKind, ctx: &TranslateContext) -> FriendlyError { // `change column ... --dont-convert` lets the engine // accept or refuse each cell. Whatever the engine returns // (constraint, datatype mismatch, …) means "the new type @@ -392,8 +384,8 @@ fn translate_constraint(message: &str, ctx: &TranslateContext) -> FriendlyError // ---- UNIQUE ----------------------------------------------------- fn translate_unique(message: &str, ctx: &TranslateContext) -> FriendlyError { - let (table, column) = parse_qualified_target(message) - .unwrap_or_else(|| (ctx_table(ctx), ctx_column(ctx))); + let (table, column) = + parse_qualified_target(message).unwrap_or_else(|| (ctx_table(ctx), ctx_column(ctx))); let value = ctx_value(ctx); match ctx.operation { Some(Operation::Update) => fe( @@ -405,11 +397,7 @@ fn translate_unique(message: &str, ctx: &TranslateContext) -> FriendlyError { ), verbose_hint( ctx, - t!( - "error.unique.update.hint", - table = table, - column = column - ), + t!("error.unique.update.hint", table = table, column = column), ), ), // Default to the INSERT variant — it's the most common @@ -425,11 +413,7 @@ fn translate_unique(message: &str, ctx: &TranslateContext) -> FriendlyError { ), verbose_hint( ctx, - t!( - "error.unique.insert.hint", - table = table, - column = column - ), + t!("error.unique.insert.hint", table = table, column = column), ), ), } @@ -542,8 +526,8 @@ fn fk_parent_side_update(ctx: &TranslateContext) -> FriendlyError { // ---- NOT NULL -------------------------------------------------- fn translate_not_null(message: &str, ctx: &TranslateContext) -> FriendlyError { - let (table, column) = parse_qualified_target(message) - .unwrap_or_else(|| (ctx_table(ctx), ctx_column(ctx))); + let (table, column) = + parse_qualified_target(message).unwrap_or_else(|| (ctx_table(ctx), ctx_column(ctx))); match ctx.operation { Some(Operation::Update) => fe( t!( @@ -576,9 +560,17 @@ fn translate_check(_message: &str, ctx: &TranslateContext) -> FriendlyError { let column = ctx_column(ctx); let is_update = matches!(ctx.operation, Some(Operation::Update)); let headline = if is_update { - t!("error.check.update.headline", table = table, column = column) + t!( + "error.check.update.headline", + table = table, + column = column + ) } else { - t!("error.check.insert.headline", table = table, column = column) + t!( + "error.check.insert.headline", + table = table, + column = column + ) }; let hint = ctx.check_rule.as_ref().map_or_else( || { @@ -613,8 +605,7 @@ fn translate_check(_message: &str, ctx: &TranslateContext) -> FriendlyError { // ---- not_found / already_exists -------------------------------- fn translate_not_found_table(message: &str, ctx: &TranslateContext) -> FriendlyError { - let name = parse_after_colon(message) - .map_or_else(|| ctx_table(ctx), str::to_string); + let name = parse_after_colon(message).map_or_else(|| ctx_table(ctx), str::to_string); headline_only(t!("error.not_found.table.headline", name = name)) } @@ -656,17 +647,11 @@ fn translate_already_exists(message: &str, ctx: &TranslateContext) -> FriendlyEr column = column )); } - return headline_only(t!( - "error.already_exists.table.headline", - name = name - )); + return headline_only(t!("error.already_exists.table.headline", name = name)); } // No backticks — engine-style "table T already exists". if let Some(name) = parse_after_word(message, "table") { - return headline_only(t!( - "error.already_exists.table.headline", - name = name - )); + return headline_only(t!("error.already_exists.table.headline", name = name)); } if let Some(name) = parse_after_word(message, "relationship") { return headline_only(t!( @@ -696,36 +681,25 @@ fn translate_generic(message: &str, ctx: &TranslateContext) -> FriendlyError { if lower.contains("misuse of aggregate") { return headline_only(t!("engine.aggregate_misuse", name = "?")); } - if lower.contains("group by") - || lower.contains("must appear in") - { + if lower.contains("group by") || lower.contains("must appear in") { return headline_only(t!("engine.group_by_required")); } - if (lower.contains("union") - || lower.contains("intersect") - || lower.contains("except")) + if (lower.contains("union") || lower.contains("intersect") || lower.contains("except")) && lower.contains("result columns") { // Last-resort safety net — the pre-flight pass in 2d.1 // catches this in most cases; if the engine surfaces it // anyway, route it through the engine-neutral key. - return headline_only(t!( - "engine.compound_arity_mismatch", - op = "set operator" - )); + return headline_only(t!("engine.compound_arity_mismatch", op = "set operator")); } if lower.contains("scalar subquery") || lower.contains("more than one row") { return headline_only(t!("engine.scalar_subquery_too_many_rows")); } - if lower.contains("recursive") - && (lower.contains("cte") || lower.contains("union")) - { + if lower.contains("recursive") && (lower.contains("cte") || lower.contains("union")) { return headline_only(t!("engine.recursive_cte_malformed")); } - let operation = ctx - .operation - .map_or("operation", Operation::keyword); + let operation = ctx.operation.map_or("operation", Operation::keyword); // F2 (ADR-0035 Amendment 1): when no table is in context, use the // table-less hint so a contextless `friendly_message()` (replay, undo, // rebuild, export) never renders a literal `{table}` placeholder. @@ -789,23 +763,33 @@ fn ctx_table(ctx: &TranslateContext) -> String { } fn ctx_column(ctx: &TranslateContext) -> String { - ctx.column.clone().unwrap_or_else(|| "the column".to_string()) + ctx.column + .clone() + .unwrap_or_else(|| "the column".to_string()) } fn ctx_value(ctx: &TranslateContext) -> String { - ctx.value.clone().unwrap_or_else(|| "that value".to_string()) + ctx.value + .clone() + .unwrap_or_else(|| "that value".to_string()) } fn ctx_parent_table(ctx: &TranslateContext) -> String { - ctx.parent_table.clone().unwrap_or_else(|| "the referenced table".to_string()) + ctx.parent_table + .clone() + .unwrap_or_else(|| "the referenced table".to_string()) } fn ctx_parent_column(ctx: &TranslateContext) -> String { - ctx.parent_column.clone().unwrap_or_else(|| "the referenced column".to_string()) + ctx.parent_column + .clone() + .unwrap_or_else(|| "the referenced column".to_string()) } fn ctx_child_table(ctx: &TranslateContext) -> String { - ctx.child_table.clone().unwrap_or_else(|| "the referencing table".to_string()) + ctx.child_table + .clone() + .unwrap_or_else(|| "the referencing table".to_string()) } /// Extract `T.col` from a message like @@ -847,11 +831,7 @@ fn parse_after_word<'a>(message: &'a str, keyword: &str) -> Option<&'a str> { let rest = message[pos..].trim_start(); let token_end = rest.find(|c: char| c.is_whitespace()).unwrap_or(rest.len()); let token = rest[..token_end].trim_matches(|c: char| c == '`' || c == '\''); - if token.is_empty() { - None - } else { - Some(token) - } + if token.is_empty() { None } else { Some(token) } } #[cfg(test)] @@ -876,15 +856,24 @@ mod tests { }; let d = TranslateContext::default; assert_eq!( - error_hint_class(&sqlite(SqliteErrorKind::NoSuchTable, "no such table: X"), &d()), + 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()), + 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()), + error_hint_class( + &sqlite(SqliteErrorKind::AlreadyExists, "already exists"), + &d() + ), Some("already_exists") ); assert_eq!( @@ -933,13 +922,19 @@ mod tests { parent_table: Some("Parent".to_string()), ..TranslateContext::default() }; - assert_eq!(error_hint_class(&fk(), &ctx), Some("foreign_key.child_side")); + 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")); + 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)), @@ -1049,14 +1044,22 @@ mod tests { ctx.parent_column = Some("country, code".to_string()); ctx.value = Some("7, 8".to_string()); let f = translate(&err, &ctx); - assert!(f.headline.contains("no parent row"), "child-side: {}", f.headline); + assert!( + f.headline.contains("no parent row"), + "child-side: {}", + f.headline + ); assert!(f.headline.contains("Region")); assert!( f.headline.contains("country, code"), "both parent columns must appear: {}", f.headline ); - assert!(f.headline.contains("`7, 8`"), "joined value: {}", f.headline); + assert!( + f.headline.contains("`7, 8`"), + "joined value: {}", + f.headline + ); } #[test] diff --git a/src/input_render.rs b/src/input_render.rs index 01af66b..abd8ee3 100644 --- a/src/input_render.rs +++ b/src/input_render.rs @@ -25,9 +25,9 @@ use ratatui::style::{Color, Modifier, Style}; use crate::dsl::parser::{parse_command_with_schema, parse_command_with_schema_in_mode}; -use crate::mode::Mode; use crate::dsl::walker; use crate::dsl::{ParseError, parse_command}; +use crate::mode::Mode; use crate::theme::Theme; /// A run of text with its byte range in the source and the @@ -85,7 +85,16 @@ pub fn render_input_runs_in_mode( mode: Mode, ) -> Vec { // Identity feedback view — highlight/overlay the whole input. - render_input_runs_feedback(input, cursor_byte, theme, cache, mode, input, cursor_byte, 0) + render_input_runs_feedback( + input, + cursor_byte, + theme, + cache, + mode, + input, + cursor_byte, + 0, + ) } /// [`render_input_runs_in_mode`] with a separate **feedback view** for @@ -121,12 +130,14 @@ pub fn render_input_runs_feedback( byte_range: (0, offset), style: ratatui::style::Style::default().fg(theme.fg), }]; - r.extend(lex_to_runs_in_mode(view, theme, mode).into_iter().map(|run| { - StyledRun { - byte_range: (run.byte_range.0 + offset, run.byte_range.1 + offset), - ..run - } - })); + r.extend( + lex_to_runs_in_mode(view, theme, mode) + .into_iter() + .map(|run| StyledRun { + byte_range: (run.byte_range.0 + offset, run.byte_range.1 + offset), + ..run + }), + ); r }; if let InputState::DefiniteErrorAt(pos) = @@ -150,7 +161,11 @@ pub fn render_input_runs_feedback( walker::Severity::Error => theme.tok_error, walker::Severity::Warning => theme.warning, }; - overlay_span(&mut runs, (diag.span.0 + offset, diag.span.1 + offset), colour); + overlay_span( + &mut runs, + (diag.span.0 + offset, diag.span.1 + offset), + colour, + ); } inject_cursor(&mut runs, input, cursor_byte, theme); runs @@ -234,9 +249,7 @@ pub fn classify_input_with_schema_in_mode( )) } -fn classify_parse_result( - result: Result, -) -> InputState { +fn classify_parse_result(result: Result) -> InputState { match result { Ok(_) => InputState::Valid, Err(ParseError::Empty) => InputState::Empty, @@ -372,8 +385,7 @@ pub fn advanced_alternative_note( // carries a blocking ERROR diagnostic such as a value-count // mismatch. Incomplete input (still being typed) and empty input are // excluded so the pointer doesn't flicker mid-keystroke. - let definite_dsl_error = match classify_input_with_schema_in_mode(input, cache, Mode::Simple) - { + let definite_dsl_error = match classify_input_with_schema_in_mode(input, cache, Mode::Simple) { InputState::DefiniteErrorAt(_) => true, InputState::Valid => { crate::dsl::walker::input_verdict_in_mode(input, Some(cache), Mode::Simple) @@ -386,8 +398,7 @@ pub fn advanced_alternative_note( } // The validity-verdict-driven gate (ADR-0033 Amendment 5): the // line must be fully valid (verdict `None`) in advanced mode. - if crate::dsl::walker::input_verdict_in_mode(input, Some(cache), Mode::Advanced).is_some() - { + if crate::dsl::walker::input_verdict_in_mode(input, Some(cache), Mode::Advanced).is_some() { return None; } Some(crate::t!("advanced_mode.also_valid_sql")) @@ -714,8 +725,7 @@ fn ambient_hint_core_in_mode( // narrows column candidates to the active table and runs the // §10.6 look-ahead, so it is the authoritative "what can go // here" set. - let completion = - crate::completion::candidates_at_cursor_in_mode(input, cursor, cache, mode); + let completion = crate::completion::candidates_at_cursor_in_mode(input, cursor, cache, mode); // Schema-aware diagnostics (ADR-0027 §2). `input_diagnostics` // is non-empty only for a command that *structurally parses* @@ -834,7 +844,9 @@ fn ambient_hint_core_in_mode( // keyword set. return Some(AmbientHint::Prose(crate::friendly::translate(key, &[]))); } - Some(crate::dsl::grammar::HintMode::SuppressProse | crate::dsl::grammar::HintMode::Default) + Some( + crate::dsl::grammar::HintMode::SuppressProse | crate::dsl::grammar::HintMode::Default, + ) | None => {} } @@ -855,7 +867,8 @@ fn ambient_hint_core_in_mode( // Invalid identifier: cursor sits in a known-set slot but // the typed prefix matches nothing in the schema. (Stage // 8e / the user's #5.) - if let Some(inv) = crate::completion::invalid_ident_at_cursor_in_mode(input, cursor, cache, mode) + if let Some(inv) = + crate::completion::invalid_ident_at_cursor_in_mode(input, cursor, cache, mode) { let kind = match inv.source { crate::dsl::grammar::IdentSource::Tables => "table", @@ -1036,11 +1049,7 @@ pub fn lex_to_runs(input: &str, theme: &Theme) -> Vec { /// with `Mode::Advanced` so SQL keywords past the entry word /// match and get highlighted (ADR-0030 §8). #[must_use] -pub fn lex_to_runs_in_mode( - input: &str, - theme: &Theme, - mode: Mode, -) -> Vec { +pub fn lex_to_runs_in_mode(input: &str, theme: &Theme, mode: Mode) -> Vec { base_runs(input, theme, mode) } @@ -1076,12 +1085,7 @@ fn base_runs(input: &str, theme: &Theme, mode: Mode) -> Vec { runs } -fn inject_cursor( - runs: &mut Vec, - input: &str, - cursor_byte: usize, - theme: &Theme, -) { +fn inject_cursor(runs: &mut Vec, input: &str, cursor_byte: usize, theme: &Theme) { let cursor_byte = cursor_byte.min(input.len()); // End-of-input cursor: append the empty-range sentinel. @@ -1164,9 +1168,10 @@ mod tests { let mut cache = SchemaCache::default(); cache.tables.push("Customers".into()); cache.columns.push("name".into()); - cache - .table_columns - .insert("Customers".into(), vec![TableColumn::new("name", Type::Text)]); + cache.table_columns.insert( + "Customers".into(), + vec![TableColumn::new("name", Type::Text)], + ); let input = ": select name from Customers"; let view = "select name from Customers"; let offset = 2; // ": " @@ -1362,9 +1367,10 @@ mod tests { let mut cache = crate::completion::SchemaCache::default(); cache.tables.push("users".to_string()); cache.columns.push("email".to_string()); - cache - .table_columns - .insert("users".to_string(), vec![TableColumn::new("email", Type::Text)]); + cache.table_columns.insert( + "users".to_string(), + vec![TableColumn::new("email", Type::Text)], + ); cache } @@ -1392,7 +1398,10 @@ mod tests { } // Tab candidates remain available (completion is independent). let comp = crate::completion::candidates_at_cursor_in_mode( - input, input.len(), &cache, Mode::Simple, + input, + input.len(), + &cache, + Mode::Simple, ) .expect("completion remains available"); let texts: Vec<&str> = comp.candidates.iter().map(|c| c.text.as_str()).collect(); @@ -1424,7 +1433,10 @@ mod tests { &hint, Some(AmbientHint::Prose(p)) if p.contains("row count") ); - assert!(!is_count_prose, "count hint must not show for {input:?}; got {hint:?}"); + assert!( + !is_count_prose, + "count hint must not show for {input:?}; got {hint:?}" + ); } } @@ -1502,14 +1514,12 @@ mod tests { use crate::dsl::types::Type; let mut cache = SchemaCache::default(); cache.tables.push("Customers".to_string()); - let tc = vec![ - TableColumn { - name: "Age".to_string(), - user_type: Type::Int, - not_null: false, - has_default: false, - }, - ]; + let tc = vec![TableColumn { + name: "Age".to_string(), + user_type: Type::Int, + not_null: false, + has_default: false, + }]; for c in &tc { cache.columns.push(c.name.clone()); } @@ -1551,9 +1561,7 @@ mod tests { p.contains("No such") && p.contains("Agx"), "a genuine column typo before FROM must warn at typing time; got: {p:?}", ), - other => panic!( - "`select Agx` must surface a typing-time typo hint; got: {other:?}", - ), + other => panic!("`select Agx` must surface a typing-time typo hint; got: {other:?}",), } } @@ -1652,8 +1660,7 @@ mod tests { // ADR-0022 Amendment 1: advanced-mode ambient assistance // surfaces SQL completion candidates (here the FROM-slot // table) instead of the simple-mode "this is SQL" gate. - let cache = - schema_with_columns("Customers", &[("id", crate::dsl::types::Type::Int)]); + let cache = schema_with_columns("Customers", &[("id", crate::dsl::types::Type::Int)]); let input = "select * from "; match ambient_hint_in_mode( input, @@ -1678,10 +1685,7 @@ mod tests { // `INSERT … (` column list. (The simple-mode DSL value-slot // prose is a separate surface; this pins the §8 advanced claim.) use crate::dsl::types::Type; - let cache = schema_with_columns( - "Customers", - &[("id", Type::Int), ("Name", Type::Text)], - ); + let cache = schema_with_columns("Customers", &[("id", Type::Int), ("Name", Type::Text)]); let set_slot = "update Customers set "; match ambient_hint_in_mode(set_slot, set_slot.len(), None, &cache, Mode::Advanced) { @@ -1706,16 +1710,10 @@ mod tests { fn simple_mode_ambient_does_not_surface_sql_candidates() { // The simple-mode entry point keeps gating SQL — advanced // assistance is opt-in via mode, never leaked into simple. - let cache = - schema_with_columns("Customers", &[("id", crate::dsl::types::Type::Int)]); + let cache = schema_with_columns("Customers", &[("id", crate::dsl::types::Type::Int)]); let input = "select * from "; - let hint = ambient_hint_in_mode( - input, - input.len(), - None, - &cache, - crate::mode::Mode::Simple, - ); + let hint = + ambient_hint_in_mode(input, input.len(), None, &cache, crate::mode::Mode::Simple); let offers_table = matches!( &hint, Some(AmbientHint::Candidates { items, .. }) @@ -1733,8 +1731,7 @@ mod tests { fn f1_mid_typed_table_prefix_shows_completion_not_error() { // "select * from c" — `c` prefix-matches `Customers`. The // hint must offer the completion, not "no such table c". - let cache = - schema_with_columns("Customers", &[("id", crate::dsl::types::Type::Int)]); + let cache = schema_with_columns("Customers", &[("id", crate::dsl::types::Type::Int)]); match ambient_hint_in_mode( "select * from c", "select * from c".len(), @@ -1753,8 +1750,7 @@ mod tests { #[test] fn f1_genuinely_unknown_table_still_shows_error() { // "zzz" matches no table prefix — the error must still show. - let cache = - schema_with_columns("Customers", &[("id", crate::dsl::types::Type::Int)]); + let cache = schema_with_columns("Customers", &[("id", crate::dsl::types::Type::Int)]); match ambient_hint_in_mode( "select * from zzz", "select * from zzz".len(), @@ -1773,8 +1769,7 @@ mod tests { fn f1_simple_mode_dsl_mid_typed_table_completes() { // The same shadowing affects DSL commands in simple mode: // "show data c" must offer Customers, not "no such table c". - let cache = - schema_with_columns("Customers", &[("id", crate::dsl::types::Type::Int)]); + let cache = schema_with_columns("Customers", &[("id", crate::dsl::types::Type::Int)]); match ambient_hint_in_mode( "show data c", "show data c".len(), @@ -1804,7 +1799,12 @@ mod tests { cache.columns.push("order_col".to_string()); cache.table_columns.insert( "Orders".to_string(), - vec![TableColumn { name: "order_col".to_string(), user_type: Type::Int, not_null: false, has_default: false }], + vec![TableColumn { + name: "order_col".to_string(), + user_type: Type::Int, + not_null: false, + has_default: false, + }], ); let comp = crate::completion::candidates_at_cursor_in_mode( @@ -1846,9 +1846,7 @@ mod tests { for c in &columns { cache.columns.push(c.name.clone()); } - cache - .table_columns - .insert(table.to_string(), columns); + cache.table_columns.insert(table.to_string(), columns); cache } @@ -1860,7 +1858,11 @@ mod tests { ("Customers", &[("id", Type::Serial), ("name", Type::Text)]), ( "Products", - &[("id", Type::Serial), ("name", Type::Text), ("price", Type::Decimal)], + &[ + ("id", Type::Serial), + ("name", Type::Text), + ("price", Type::Decimal), + ], ), ( "OrderLines", @@ -1873,13 +1875,19 @@ mod tests { ), ( "Orders", - &[("id", Type::Serial), ("customer_id", Type::Int), ("date", Type::Date)], + &[ + ("id", Type::Serial), + ("customer_id", Type::Int), + ("date", Type::Date), + ], ), ]; for (t, cols) in tables { cache.tables.push((*t).to_string()); - let tc: Vec = - cols.iter().map(|(n, ty)| TableColumn::new(*n, *ty)).collect(); + let tc: Vec = cols + .iter() + .map(|(n, ty)| TableColumn::new(*n, *ty)) + .collect(); for c in &tc { cache.columns.push(c.name.clone()); } @@ -1914,17 +1922,11 @@ mod tests { #[test] fn ambient_hint_at_insert_first_value_shows_int_prose() { use crate::dsl::types::Type; - let cache = schema_with_columns( - "Customers", - &[("id", Type::Int), ("Name", Type::Text)], - ); + let cache = schema_with_columns("Customers", &[("id", Type::Int), ("Name", Type::Text)]); let input = "insert into Customers values ("; match ambient_hint(input, input.len(), None, &cache) { Some(AmbientHint::Prose(p)) => { - assert!( - p.contains("integer"), - "expected int-slot prose, got: {p:?}", - ); + assert!(p.contains("integer"), "expected int-slot prose, got: {p:?}",); } other => panic!("expected Prose, got {other:?}"), } @@ -1942,7 +1944,11 @@ mod tests { use crate::dsl::types::Type; let cache = schema_with_columns( "Customers", - &[("id", Type::Serial), ("Name", Type::Text), ("Email", Type::Text)], + &[ + ("id", Type::Serial), + ("Name", Type::Text), + ("Email", Type::Text), + ], ); let input = "insert into Customers values (1, 'Alice', 'a@b.c')"; match ambient_hint(input, input.len(), None, &cache) { @@ -1966,10 +1972,7 @@ mod tests { // A valid simple-mode DSL command gets no advanced pointer — // it isn't an error, and there is nothing to switch modes for. use crate::dsl::types::Type; - let cache = schema_with_columns( - "Customers", - &[("id", Type::Serial), ("Name", Type::Text)], - ); + let cache = schema_with_columns("Customers", &[("id", Type::Serial), ("Name", Type::Text)]); let input = "insert into Customers values ('Alice')"; if let Some(AmbientHint::Prose(p)) = ambient_hint(input, input.len(), None, &cache) { assert!( @@ -2010,10 +2013,7 @@ mod tests { #[test] fn ambient_hint_at_insert_second_value_shows_text_prose() { use crate::dsl::types::Type; - let cache = schema_with_columns( - "Customers", - &[("id", Type::Int), ("Name", Type::Text)], - ); + let cache = schema_with_columns("Customers", &[("id", Type::Int), ("Name", Type::Text)]); let input = "insert into Customers values (1, "; match ambient_hint(input, input.len(), None, &cache) { Some(AmbientHint::Prose(p)) => { @@ -2029,10 +2029,8 @@ mod tests { #[test] fn ambient_hint_at_update_set_shows_per_column_prose() { use crate::dsl::types::Type; - let cache = schema_with_columns( - "Customers", - &[("id", Type::Int), ("Birthday", Type::Date)], - ); + let cache = + schema_with_columns("Customers", &[("id", Type::Int), ("Birthday", Type::Date)]); let input = "update Customers set Birthday="; match ambient_hint(input, input.len(), None, &cache) { Some(AmbientHint::Prose(p)) => { @@ -2057,9 +2055,7 @@ mod tests { // hasn't committed), the Optional propagates Incomplete // and the user sees no error overlay until they submit. assert_eq!( - classify_input( - "insert into Orders (id, CustId, Total) values (42, 89, 17.59" - ), + classify_input("insert into Orders (id, CustId, Total) values (42, 89, 17.59"), InputState::IncompleteAtEof, ); assert_eq!( @@ -2071,18 +2067,12 @@ mod tests { #[test] fn ambient_hint_at_insert_first_value_mentions_column_name() { use crate::dsl::types::Type; - let cache = schema_with_columns( - "Customers", - &[("id", Type::Int), ("Name", Type::Text)], - ); + let cache = schema_with_columns("Customers", &[("id", Type::Int), ("Name", Type::Text)]); let input = "insert into Customers values ("; match ambient_hint(input, input.len(), None, &cache) { Some(AmbientHint::Prose(p)) => { assert!(p.contains("id"), "expected column name `id`, got {p:?}"); - assert!( - p.contains("integer"), - "expected int prose, got {p:?}", - ); + assert!(p.contains("integer"), "expected int prose, got {p:?}",); } other => panic!("expected Prose, got {other:?}"), } @@ -2091,10 +2081,7 @@ mod tests { #[test] fn ambient_hint_at_update_set_mentions_column_name() { use crate::dsl::types::Type; - let cache = schema_with_columns( - "Customers", - &[("id", Type::Int), ("Email", Type::Text)], - ); + let cache = schema_with_columns("Customers", &[("id", Type::Int), ("Email", Type::Text)]); let input = "update Customers set Email="; match ambient_hint(input, input.len(), None, &cache) { Some(AmbientHint::Prose(p)) => { @@ -2131,10 +2118,7 @@ mod tests { #[test] fn ambient_hint_at_second_insert_value_mentions_second_column() { use crate::dsl::types::Type; - let cache = schema_with_columns( - "Customers", - &[("id", Type::Int), ("Name", Type::Text)], - ); + let cache = schema_with_columns("Customers", &[("id", Type::Int), ("Name", Type::Text)]); let input = "insert into Customers values (1, "; match ambient_hint(input, input.len(), None, &cache) { Some(AmbientHint::Prose(p)) => { @@ -2165,14 +2149,20 @@ mod tests { use crate::dsl::types::Type; let cases: &[(&[(&str, Type)], &str)] = &[ // string first value (the report's case): first col text. - (&[("Name", Type::Text), ("Age", Type::Int)], - "insert into Customers values ('Oli'"), + ( + &[("Name", Type::Text), ("Age", Type::Int)], + "insert into Customers values ('Oli'", + ), // integer first value: first col int. - (&[("Age", Type::Int), ("Name", Type::Text)], - "insert into Customers values (42"), + ( + &[("Age", Type::Int), ("Name", Type::Text)], + "insert into Customers values (42", + ), // real first value: first col real. - (&[("Score", Type::Real), ("Name", Type::Text)], - "insert into Customers values (3.5"), + ( + &[("Score", Type::Real), ("Name", Type::Text)], + "insert into Customers values (3.5", + ), ]; for (cols, input) in cases { let cache = schema_with_columns("Customers", cols); @@ -2232,10 +2222,7 @@ mod tests { // is nothing left to fill. Guards against over-correcting the // fix into never suggesting the close paren. use crate::dsl::types::Type; - let cache = schema_with_columns( - "Customers", - &[("Name", Type::Text), ("Age", Type::Int)], - ); + let cache = schema_with_columns("Customers", &[("Name", Type::Text), ("Age", Type::Int)]); let input = "insert into Customers values ('Oli', 52"; match ambient_hint(input, input.len(), None, &cache) { Some(AmbientHint::Prose(p)) => { @@ -2384,10 +2371,7 @@ mod tests { match ambient_hint("show data Missing", 17, None, &cache) { Some(AmbientHint::Prose(p)) => { assert!(p.contains("Missing"), "got {p:?}"); - assert!( - p.to_lowercase().contains("no such table"), - "got {p:?}", - ); + assert!(p.to_lowercase().contains("no such table"), "got {p:?}",); } other => panic!("expected Prose, got {other:?}"), } @@ -2440,8 +2424,7 @@ mod tests { use crate::dsl::types::Type; // Two type-mismatch WARNINGs; the hint names the column // whose offending literal the cursor sits in. - let cache = - schema_with_columns("Events", &[("a", Type::Int), ("b", Type::Int)]); + let cache = schema_with_columns("Events", &[("a", Type::Int), ("b", Type::Int)]); let input = "delete from Events where a = 'x' or b = 'y'"; let on_x = input.find("'x'").expect("'x' literal") + 1; let on_y = input.find("'y'").expect("'y' literal") + 1; @@ -2460,8 +2443,16 @@ mod tests { inserted_range: (5, 5), original_text: String::new(), candidates: vec![ - Candidate { text: "data".to_string(), kind: CandidateKind::Keyword, mode: crate::completion::ModeClass::Both }, - Candidate { text: "table".to_string(), kind: CandidateKind::Keyword, mode: crate::completion::ModeClass::Both }, + Candidate { + text: "data".to_string(), + kind: CandidateKind::Keyword, + mode: crate::completion::ModeClass::Both, + }, + Candidate { + text: "table".to_string(), + kind: CandidateKind::Keyword, + mode: crate::completion::ModeClass::Both, + }, ], selection_idx: 1, }; @@ -2494,8 +2485,16 @@ mod tests { // produce — proves the memo's list is being used, // not a recomputed one. candidates: vec![ - Candidate { text: "data".to_string(), kind: CandidateKind::Keyword, mode: crate::completion::ModeClass::Both }, - Candidate { text: "table".to_string(), kind: CandidateKind::Keyword, mode: crate::completion::ModeClass::Both }, + Candidate { + text: "data".to_string(), + kind: CandidateKind::Keyword, + mode: crate::completion::ModeClass::Both, + }, + Candidate { + text: "table".to_string(), + kind: CandidateKind::Keyword, + mode: crate::completion::ModeClass::Both, + }, ], selection_idx: 1, }; @@ -2564,10 +2563,7 @@ mod tests { fn classify_trailing_whitespace_does_not_create_definite_error() { // Trailing whitespace alone shouldn't promote an // incomplete-at-EOF state into a definite error. - assert_eq!( - classify_input("create "), - InputState::IncompleteAtEof, - ); + assert_eq!(classify_input("create "), InputState::IncompleteAtEof,); } #[test] diff --git a/src/logging.rs b/src/logging.rs index 6183e49..5e6e9c9 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -60,8 +60,8 @@ pub fn init(path: Option<&Path>) -> Result { .with_context(|| format!("create log directory {}", parent.display()))?; } let file = open_log_file(&chosen)?; - let filter = EnvFilter::try_from_env("RDBMS_PLAYGROUND_LOG") - .unwrap_or_else(|_| EnvFilter::new("info")); + let filter = + EnvFilter::try_from_env("RDBMS_PLAYGROUND_LOG").unwrap_or_else(|_| EnvFilter::new("info")); let layer = fmt::layer() .with_writer(file) .with_ansi(false) @@ -95,8 +95,7 @@ fn home_dir() -> Option { if let Some(p) = std::env::var_os("HOME") { return Some(PathBuf::from(p)); } - if let (Some(drive), Some(path)) = - (std::env::var_os("HOMEDRIVE"), std::env::var_os("HOMEPATH")) + if let (Some(drive), Some(path)) = (std::env::var_os("HOMEDRIVE"), std::env::var_os("HOMEPATH")) { let mut combined = PathBuf::from(drive); combined.push(path); diff --git a/src/main.rs b/src/main.rs index af54091..452970e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,6 @@ use std::process::ExitCode; -use rdbms_playground::cli::{help_text, version_text, Args}; +use rdbms_playground::cli::{Args, help_text, version_text}; use rdbms_playground::{logging, runtime}; fn main() -> ExitCode { diff --git a/src/output_render.rs b/src/output_render.rs index ed00a6d..ac2654a 100644 --- a/src/output_render.rs +++ b/src/output_render.rs @@ -172,7 +172,10 @@ fn constraint_lines(desc: &TableDescription) -> Vec { /// A `detail` matching no marker renders neutral — the engine's /// plan vocabulary may grow (ADR-0028 §4). const PLAN_TAXONOMY: &[(&str, OutputStyleClass)] = &[ - ("USING AUTOMATIC COVERING INDEX", OutputStyleClass::AutomaticIndex), + ( + "USING AUTOMATIC COVERING INDEX", + OutputStyleClass::AutomaticIndex, + ), ("USING AUTOMATIC INDEX", OutputStyleClass::AutomaticIndex), ("USING COVERING INDEX", OutputStyleClass::Efficient), ("USING INTEGER PRIMARY KEY", OutputStyleClass::Efficient), @@ -225,8 +228,7 @@ fn render_plan_subtree( emitted: &mut HashSet, mode: Mode, ) { - let children: Vec<&ExplainRow> = - rows.iter().filter(|r| r.parent == parent).collect(); + let children: Vec<&ExplainRow> = rows.iter().filter(|r| r.parent == parent).collect(); let last_idx = children.len().saturating_sub(1); for (idx, row) in children.iter().enumerate() { if !emitted.insert(row.id) { @@ -235,8 +237,7 @@ fn render_plan_subtree( let is_last = idx == last_idx; let connector = if is_last { "└─ " } else { "├─ " }; out.push(plan_node_line(prefix, connector, &row.detail, mode)); - let child_prefix = - format!("{prefix}{}", if is_last { " " } else { "│ " }); + let child_prefix = format!("{prefix}{}", if is_last { " " } else { "│ " }); render_plan_subtree(rows, row.id, &child_prefix, out, emitted, mode); } } @@ -343,13 +344,8 @@ pub fn render_diagnostic_table( const fn alignment_for(ty: Option) -> Alignment { match ty { Some(Type::Int | Type::Real | Type::Decimal | Type::Serial) => Alignment::Right, - Some(Type::Text) - | Some(Type::Bool) - | Some(Type::Date) - | Some(Type::DateTime) - | Some(Type::Blob) - | Some(Type::ShortId) - | None => Alignment::Left, + Some(Type::Text) | Some(Type::Bool) | Some(Type::Date) | Some(Type::DateTime) + | Some(Type::Blob) | Some(Type::ShortId) | None => Alignment::Left, } } @@ -406,11 +402,7 @@ fn cell_width(s: &str) -> usize { /// Render a single bordered table given header cells, body /// rows, and per-column alignment. Outer frame + /// header-underline only. -fn render_table( - headers: &[String], - body: &[Vec], - alignments: &[Alignment], -) -> Vec { +fn render_table(headers: &[String], body: &[Vec], alignments: &[Alignment]) -> Vec { debug_assert_eq!(headers.len(), alignments.len()); // Compute column widths: max(header, all body cells). @@ -792,13 +784,12 @@ fn gutter_seg(i: usize, child_rows: &[usize], parent_rows: &[usize], w: usize) - } // The vertical bus spans the full range of endpoint rows. - let bounds = child_rows - .iter() - .chain(parent_rows) - .copied() - .fold(None, |acc: Option<(usize, usize)>, r| { + let bounds = child_rows.iter().chain(parent_rows).copied().fold( + None, + |acc: Option<(usize, usize)>, r| { Some(acc.map_or((r, r), |(lo, hi)| (lo.min(r), hi.max(r)))) - }); + }, + ); if let Some((top, bot)) = bounds && i >= top && i <= bot @@ -1138,7 +1129,10 @@ mod tests { assert!(out.contains("customer_id ●"), "FK marker:\n{out}"); assert!(out.contains("id (PK) ●"), "parent endpoint marker:\n{out}"); assert!(out.contains('▶'), "arrowhead:\n{out}"); - assert!(out.contains('n') && out.contains('1'), "cardinality:\n{out}"); + assert!( + out.contains('n') && out.contains('1'), + "cardinality:\n{out}" + ); assert!( out.contains("on delete cascade · on update no action"), "actions:\n{out}" @@ -1237,7 +1231,10 @@ mod tests { let (r_out, r_in) = blank_rels(); let region = TableDescription { name: "Region".to_string(), - columns: vec![col("country", Type::Int, true, false), col("code", Type::Int, true, false)], + columns: vec![ + col("country", Type::Int, true, false), + col("code", Type::Int, true, false), + ], outbound_relationships: r_out, inbound_relationships: r_in, indexes: Vec::new(), @@ -1277,7 +1274,10 @@ mod tests { .collect::>() .join("\n"); assert!(text.contains("region_code ●"), "child endpoint 2:\n{text}"); - assert!(text.contains("(PK) ●"), "parent endpoint is PK + marked:\n{text}"); + assert!( + text.contains("(PK) ●"), + "parent endpoint is PK + marked:\n{text}" + ); assert!( text.contains("(country, region_code) ▶ Region.(country, code)"), "pairing line:\n{text}", @@ -1412,11 +1412,7 @@ mod tests { let data = DataResult { table_name: "Customers".to_string(), columns: vec!["id".to_string(), "Name".to_string(), "Email".to_string()], - column_types: vec![ - Some(Type::Serial), - Some(Type::Text), - Some(Type::Text), - ], + column_types: vec![Some(Type::Serial), Some(Type::Text), Some(Type::Text)], rows: vec![ vec![ Some("1".to_string()), @@ -1634,7 +1630,10 @@ mod tests { assert!(out.contains("Indexes:"), "got:\n{out}"); assert!(out.contains("idx_email (Email)"), "got:\n{out}"); // A plain index carries no uniqueness marker. - assert!(!out.contains("[unique]"), "plain index unmarked; got:\n{out}"); + assert!( + !out.contains("[unique]"), + "plain index unmarked; got:\n{out}" + ); } #[test] @@ -1677,7 +1676,10 @@ mod tests { indexes: Vec::new(), unique_constraints: vec![vec!["a".to_string(), "b".to_string()]], check_constraints: vec![ - crate::persistence::TableCheck { name: None, expr: "a < b".to_string() }, + crate::persistence::TableCheck { + name: None, + expr: "a < b".to_string(), + }, crate::persistence::TableCheck { name: Some("a_lt_b".to_string()), expr: "a <> b".to_string(), @@ -1691,7 +1693,10 @@ mod tests { // (ADR-0035 Amendment 1) so the user can `drop constraint `. assert!(out.contains("unique_a_b: unique (a, b)"), "got:\n{out}"); assert!(out.contains("check (a < b)"), "unnamed check; got:\n{out}"); - assert!(out.contains("check a_lt_b (a <> b)"), "named check shows its name; got:\n{out}"); + assert!( + out.contains("check a_lt_b (a <> b)"), + "named check shows its name; got:\n{out}" + ); } #[test] @@ -1732,17 +1737,37 @@ mod tests { let plan = QueryPlan { display_sql: "SELECT 1".to_string(), rows: vec![ - ExplainRow { id: 1, parent: 0, detail: "root".to_string() }, - ExplainRow { id: 2, parent: 1, detail: "child-a".to_string() }, - ExplainRow { id: 3, parent: 1, detail: "child-b".to_string() }, + ExplainRow { + id: 1, + parent: 0, + detail: "root".to_string(), + }, + ExplainRow { + id: 2, + parent: 1, + detail: "child-a".to_string(), + }, + ExplainRow { + id: 3, + parent: 1, + detail: "child-b".to_string(), + }, ], }; let lines = render_explain_plan(&plan, Mode::Simple); // display SQL + 3 plan nodes. assert_eq!(lines.len(), 4); assert!(lines[1].text.contains("root")); - assert!(lines[2].text.contains("├─ child-a"), "got {:?}", lines[2].text); - assert!(lines[3].text.contains("└─ child-b"), "got {:?}", lines[3].text); + assert!( + lines[2].text.contains("├─ child-a"), + "got {:?}", + lines[2].text + ); + assert!( + lines[3].text.contains("└─ child-b"), + "got {:?}", + lines[3].text + ); // The single root uses `└─`; its children are indented // by three spaces (no `│` spine, the root being last). assert!(lines[1].text.starts_with("└─ root")); @@ -1775,7 +1800,10 @@ mod tests { fn render_explain_plan_colours_a_full_scan_expensive() { let plan = one_node_plan("SCAN Customers"); let lines = render_explain_plan(&plan, Mode::Simple); - assert_eq!(span_class_for(&lines[1], "SCAN"), OutputStyleClass::Expensive); + assert_eq!( + span_class_for(&lines[1], "SCAN"), + OutputStyleClass::Expensive + ); // The table name stays neutral (ADR-0028 §6). assert_eq!( span_class_for(&lines[1], "Customers"), @@ -1801,8 +1829,7 @@ mod tests { #[test] fn render_explain_plan_flags_an_automatic_index() { - let plan = - one_node_plan("SEARCH Orders USING AUTOMATIC COVERING INDEX (CustId=?)"); + let plan = one_node_plan("SEARCH Orders USING AUTOMATIC COVERING INDEX (CustId=?)"); let lines = render_explain_plan(&plan, Mode::Simple); assert_eq!( span_class_for(&lines[1], "USING AUTOMATIC COVERING INDEX"), diff --git a/src/persistence/csv_io.rs b/src/persistence/csv_io.rs index e116da5..51ba8a3 100644 --- a/src/persistence/csv_io.rs +++ b/src/persistence/csv_io.rs @@ -150,7 +150,9 @@ fn encode_cell(ty: Type, value: &CellValue) -> Result { other => Err(format!("expected date/datetime (text), got {other:?}")), }, Type::Blob => match value { - CellValue::Blob(bytes) => Ok(Cell::Plain(base64::engine::general_purpose::STANDARD.encode(bytes))), + CellValue::Blob(bytes) => Ok(Cell::Plain( + base64::engine::general_purpose::STANDARD.encode(bytes), + )), other => Err(format!("expected blob, got {other:?}")), }, Type::Serial => match value { @@ -169,7 +171,11 @@ fn format_real(f: f64) -> String { if f.is_nan() { "nan".to_string() } else if f.is_infinite() { - if f > 0.0 { "inf".to_string() } else { "-inf".to_string() } + if f > 0.0 { + "inf".to_string() + } else { + "-inf".to_string() + } } else { // Default `{}` formatting on f64 emits a shortest // round-tripping decimal — exactly what the ADR asks @@ -318,8 +324,7 @@ fn parse_field(bytes: &[u8]) -> Result<(RawCell, usize), CsvError> { _ => i += 1, } } - let content = - String::from_utf8(bytes[..i].to_vec()).map_err(|_| CsvError::InvalidUtf8)?; + let content = String::from_utf8(bytes[..i].to_vec()).map_err(|_| CsvError::InvalidUtf8)?; Ok(( RawCell { content, @@ -435,7 +440,10 @@ mod tests { name: "T".to_string(), columns: vec![col("n", Type::Int), col("r", Type::Real)], rows: vec![ - vec![CellValue::Integer(42), CellValue::Real(std::f64::consts::PI)], + vec![ + CellValue::Integer(42), + CellValue::Real(std::f64::consts::PI), + ], vec![CellValue::Integer(-7), CellValue::Real(0.0)], ], }) @@ -452,10 +460,7 @@ mod tests { let body = serialize_table(&TableSnapshot { name: "T".to_string(), columns: vec![col("b", Type::Bool)], - rows: vec![ - vec![CellValue::Integer(1)], - vec![CellValue::Integer(0)], - ], + rows: vec![vec![CellValue::Integer(1)], vec![CellValue::Integer(0)]], }) .unwrap(); let s = String::from_utf8(body).unwrap(); @@ -555,13 +560,21 @@ mod tests { let body = serialize_table(&table).unwrap(); let parsed = parse_csv(std::str::from_utf8(&body).unwrap()).unwrap(); let row = &parsed.rows[0]; - assert!(matches!(decode_cell(Type::Int, &row[0]).unwrap(), CellValue::Integer(42))); + assert!(matches!( + decode_cell(Type::Int, &row[0]).unwrap(), + CellValue::Integer(42) + )); match decode_cell(Type::Real, &row[1]).unwrap() { CellValue::Real(f) => assert!((f - std::f64::consts::PI).abs() < 1e-12), other => panic!("got {other:?}"), } - assert!(matches!(decode_cell(Type::Bool, &row[2]).unwrap(), CellValue::Integer(1))); - assert!(matches!(decode_cell(Type::Blob, &row[3]).unwrap(), CellValue::Blob(b) if b == b"hi")); + assert!(matches!( + decode_cell(Type::Bool, &row[2]).unwrap(), + CellValue::Integer(1) + )); + assert!( + matches!(decode_cell(Type::Blob, &row[3]).unwrap(), CellValue::Blob(b) if b == b"hi") + ); } #[test] @@ -572,7 +585,10 @@ mod tests { #[test] fn decode_cell_reports_friendly_error_for_bad_int() { - let cell = RawCell { content: "abc".to_string(), was_quoted: false }; + let cell = RawCell { + content: "abc".to_string(), + was_quoted: false, + }; let err = decode_cell(Type::Int, &cell).expect_err("must error"); assert!(err.contains("integer")); assert!(err.contains("abc")); diff --git a/src/persistence/history.rs b/src/persistence/history.rs index 9fff901..b2296a2 100644 --- a/src/persistence/history.rs +++ b/src/persistence/history.rs @@ -108,10 +108,7 @@ pub(super) fn read_recent_sources( }); } }; - let mut sources: Vec = body - .lines() - .filter_map(parse_record_source) - .collect(); + let mut sources: Vec = body.lines().filter_map(parse_record_source).collect(); if sources.len() > max_n { let skip = sources.len() - max_n; sources.drain(0..skip); @@ -187,12 +184,26 @@ fn looks_like_iso8601(s: &str) -> bool { return false; } let digit = |i: usize| b[i].is_ascii_digit(); - digit(0) && digit(1) && digit(2) && digit(3) && b[4] == b'-' - && digit(5) && digit(6) && b[7] == b'-' - && digit(8) && digit(9) && b[10] == b'T' - && digit(11) && digit(12) && b[13] == b':' - && digit(14) && digit(15) && b[16] == b':' - && digit(17) && digit(18) && b[19] == b'Z' + digit(0) + && digit(1) + && digit(2) + && digit(3) + && b[4] == b'-' + && digit(5) + && digit(6) + && b[7] == b'-' + && digit(8) + && digit(9) + && b[10] == b'T' + && digit(11) + && digit(12) + && b[13] == b':' + && digit(14) + && digit(15) + && b[16] == b':' + && digit(17) + && digit(18) + && b[19] == b'Z' } fn unescape_command(s: &str) -> String { @@ -321,10 +332,8 @@ mod tests { #[test] fn parse_journal_record_ok_extracts_unescaped_source() { - let rec = parse_journal_record( - "2026-05-24T10:00:00Z|ok|create table T with pk id(int)", - ) - .expect("valid ok journal record"); + let rec = parse_journal_record("2026-05-24T10:00:00Z|ok|create table T with pk id(int)") + .expect("valid ok journal record"); assert!(rec.status_is_ok); assert_eq!(rec.source, "create table T with pk id(int)"); } @@ -370,8 +379,8 @@ mod tests { fn parse_journal_record_preserves_pipe_in_source() { // `|` is not escaped by the writer (it's a valid SQL char); // `splitn(3, '|')` keeps everything after the second `|`. - let rec = parse_journal_record("2026-05-24T10:00:00Z|ok|select 'a|b' from t") - .expect("ok record"); + let rec = + parse_journal_record("2026-05-24T10:00:00Z|ok|select 'a|b' from t").expect("ok record"); assert_eq!(rec.source, "select 'a|b' from t"); } @@ -406,7 +415,10 @@ mod tests { #[test] fn iso8601_known_seconds() { assert_eq!(iso8601_from_unix_secs(0), "1970-01-01T00:00:00Z"); - assert_eq!(iso8601_from_unix_secs(1_778_112_000), "2026-05-07T00:00:00Z"); + assert_eq!( + iso8601_from_unix_secs(1_778_112_000), + "2026-05-07T00:00:00Z" + ); } #[test] @@ -437,7 +449,10 @@ mod tests { .collect(); std::fs::write(&path, body).unwrap(); let got = read_recent_sources(&path, 3).unwrap(); - assert_eq!(got, vec!["cmd7".to_string(), "cmd8".to_string(), "cmd9".to_string()]); + assert_eq!( + got, + vec!["cmd7".to_string(), "cmd8".to_string(), "cmd9".to_string()] + ); } #[test] diff --git a/src/persistence/migrations.rs b/src/persistence/migrations.rs index 9ea3810..cce9b59 100644 --- a/src/persistence/migrations.rs +++ b/src/persistence/migrations.rs @@ -82,7 +82,10 @@ impl Default for MigratorRegistry { #[derive(Debug)] pub enum MigrateError { VersionParse(String), - NewerThanSupported { file: u32, latest: u32 }, + NewerThanSupported { + file: u32, + latest: u32, + }, NoMigratorForVersion(u32), StepFailed { from: u32, @@ -108,10 +111,9 @@ impl std::fmt::Display for MigrateError { file = file, latest = latest, )), - Self::NoMigratorForVersion(v) => f.write_str(&crate::t!( - "persistence.migrate.no_migrator", - version = v, - )), + Self::NoMigratorForVersion(v) => { + f.write_str(&crate::t!("persistence.migrate.no_migrator", version = v,)) + } Self::StepFailed { from, to, source } => f.write_str(&crate::t!( "persistence.migrate.step_failed", from = from, @@ -192,8 +194,11 @@ pub fn migrate_to_latest( // Write the .bak before any transformation runs so a // mid-migration crash leaves the original recoverable. - let bak_path = - project_path.join(format!("{}.v{}.bak", crate::project::PROJECT_YAML, file_version)); + let bak_path = project_path.join(format!( + "{}.v{}.bak", + crate::project::PROJECT_YAML, + file_version + )); std::fs::write(&bak_path, body).map_err(|source| MigrateError::Io { path: bak_path.clone(), source, @@ -214,8 +219,8 @@ pub fn migrate_to_latest( // Sanity: the new body must declare the next version. // If a migrator forgets to bump, we'd loop endlessly // through the chain — catch it here. - let advertised = read_version(&next_body) - .map_err(|e| MigrateError::BadOutput(e.to_string()))?; + let advertised = + read_version(&next_body).map_err(|e| MigrateError::BadOutput(e.to_string()))?; if advertised != v + 1 { return Err(MigrateError::BadOutput(format!( "v{v}→v{} migrator left version field at {advertised}", @@ -281,9 +286,8 @@ fn read_version(body: &str) -> Result { struct VersionOnly { version: u32, } - let v: VersionOnly = serde_norway::from_str(body).map_err(|e| { - MigrateError::VersionParse(e.to_string()) - })?; + let v: VersionOnly = + serde_norway::from_str(body).map_err(|e| MigrateError::VersionParse(e.to_string()))?; Ok(v.version) } @@ -309,12 +313,8 @@ mod tests { #[test] fn no_migration_runs_when_body_already_latest() { let tmp = tempdir(); - let outcome = migrate_to_latest( - &v1_body(), - &MigratorRegistry::production(), - tmp.path(), - ) - .unwrap(); + let outcome = + migrate_to_latest(&v1_body(), &MigratorRegistry::production(), tmp.path()).unwrap(); assert_eq!(outcome.body, v1_body()); assert_eq!(outcome.migrated_from, None); // No .bak written when nothing migrated. @@ -328,7 +328,13 @@ mod tests { let err = migrate_to_latest(body, &MigratorRegistry::production(), Path::new("/tmp")) .expect_err("must reject"); assert!( - matches!(err, MigrateError::NewerThanSupported { file: 99, latest: 1 }), + matches!( + err, + MigrateError::NewerThanSupported { + file: 99, + latest: 1 + } + ), "got: {err:?}", ); } @@ -366,12 +372,7 @@ mod tests { #[test] fn migrate_runs_chain_and_writes_bak() { let tmp = tempdir(); - let outcome = migrate_to_latest( - &v1_body(), - ®istry_with_v1_to_v2(), - tmp.path(), - ) - .unwrap(); + let outcome = migrate_to_latest(&v1_body(), ®istry_with_v1_to_v2(), tmp.path()).unwrap(); assert_eq!(outcome.migrated_from, Some(1)); assert!(outcome.body.contains("version: 2")); let bak = tmp.path().join("project.yaml.v1.bak"); @@ -396,11 +397,8 @@ mod tests { let tmp = tempdir(); let yaml_path = tmp.path().join("project.yaml"); std::fs::write(&yaml_path, v1_body()).unwrap(); - let outcome = ensure_project_yaml_migrated( - tmp.path(), - &MigratorRegistry::production(), - ) - .unwrap(); + let outcome = + ensure_project_yaml_migrated(tmp.path(), &MigratorRegistry::production()).unwrap(); assert_eq!(outcome.migrated_from, None); // File unchanged. let on_disk = std::fs::read_to_string(&yaml_path).unwrap(); @@ -413,36 +411,32 @@ mod tests { let tmp = tempdir(); let yaml_path = tmp.path().join("project.yaml"); std::fs::write(&yaml_path, v1_body()).unwrap(); - let outcome = ensure_project_yaml_migrated( - tmp.path(), - ®istry_with_v1_to_v2(), - ) - .unwrap(); + let outcome = ensure_project_yaml_migrated(tmp.path(), ®istry_with_v1_to_v2()).unwrap(); assert_eq!(outcome.migrated_from, Some(1)); let on_disk = std::fs::read_to_string(&yaml_path).unwrap(); assert!(on_disk.contains("version: 2"), "got: {on_disk}"); let bak = tmp.path().join("project.yaml.v1.bak"); assert!(bak.exists()); - assert!(std::fs::read_to_string(&bak).unwrap().contains("version: 1")); + assert!( + std::fs::read_to_string(&bak) + .unwrap() + .contains("version: 1") + ); } #[test] fn ensure_yaml_migrated_handles_missing_yaml() { let tmp = tempdir(); // No project.yaml exists. - let outcome = ensure_project_yaml_migrated( - tmp.path(), - &MigratorRegistry::production(), - ) - .unwrap(); + let outcome = + ensure_project_yaml_migrated(tmp.path(), &MigratorRegistry::production()).unwrap(); assert_eq!(outcome.migrated_from, None); assert!(outcome.body.is_empty()); } #[test] fn migrator_that_returns_internal_error_propagates() { - let bad: MigrateFn = - |_| Err(MigrateError::VersionParse("simulated".to_string())); + let bad: MigrateFn = |_| Err(MigrateError::VersionParse("simulated".to_string())); let registry = MigratorRegistry { migrators: vec![bad], }; diff --git a/src/persistence/mod.rs b/src/persistence/mod.rs index 745d3c4..3d5234e 100644 --- a/src/persistence/mod.rs +++ b/src/persistence/mod.rs @@ -368,12 +368,11 @@ impl Persistence { path: data_dir.clone(), source, })?; - let body = - csv_io::serialize_table(table).map_err(|message| PersistenceError::Encode { - kind: "CSV", - path: data_dir.join(format!("{}.csv", table.name)), - message, - })?; + let body = csv_io::serialize_table(table).map_err(|message| PersistenceError::Encode { + kind: "CSV", + path: data_dir.join(format!("{}.csv", table.name)), + message, + })?; atomic_write(&data_dir.join(format!("{}.csv", table.name)), &body) } @@ -406,11 +405,8 @@ impl Persistence { ) -> Result<(), PersistenceError> { let path = self.project_path.join(HISTORY_LOG); let status = history::status_token(history::STATUS_OK, advanced); - let line = history::format_record_with_status( - command_text, - history::utc_iso8601_now(), - &status, - ); + 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" @@ -432,11 +428,8 @@ impl Persistence { ) -> Result<(), PersistenceError> { let path = self.project_path.join(HISTORY_LOG); let status = history::status_token(history::STATUS_ERR, advanced); - let line = history::format_record_with_status( - command_text, - history::utc_iso8601_now(), - &status, - ); + let line = + history::format_record_with_status(command_text, history::utc_iso8601_now(), &status); debug!( len = command_text.len(), advanced, "persist: append err record to history.log" @@ -531,8 +524,14 @@ mod tests { #[test] fn extension_with_tmp_appends_to_existing_extension() { - assert_eq!(extension_with_tmp(Path::new("a/b/project.yaml")), "yaml.tmp"); - assert_eq!(extension_with_tmp(Path::new("a/b/Customers.csv")), "csv.tmp"); + assert_eq!( + extension_with_tmp(Path::new("a/b/project.yaml")), + "yaml.tmp" + ); + assert_eq!( + extension_with_tmp(Path::new("a/b/Customers.csv")), + "csv.tmp" + ); assert_eq!(extension_with_tmp(Path::new("a/b/lockfile")), "tmp"); } @@ -600,7 +599,8 @@ mod tests { fn append_history_creates_and_appends() { let dir = tempdir(); let p = Persistence::new(dir.path().to_path_buf()); - p.append_history("create table Foo with pk id(serial)", false).unwrap(); + p.append_history("create table Foo with pk id(serial)", false) + .unwrap(); p.append_history("insert into Foo (1)", false).unwrap(); let body = fs::read_to_string(dir.path().join(HISTORY_LOG)).unwrap(); let lines: Vec<&str> = body.trim_end().lines().collect(); diff --git a/src/persistence/yaml.rs b/src/persistence/yaml.rs index 518a288..3a4fcd6 100644 --- a/src/persistence/yaml.rs +++ b/src/persistence/yaml.rs @@ -261,7 +261,10 @@ fn needs_quoting(s: &str) -> bool { } // Scalar text that looks like a YAML keyword needs quoting // even if every character is safe. - if matches!(s, "true" | "false" | "null" | "~" | "yes" | "no" | "on" | "off") { + if matches!( + s, + "true" | "false" | "null" | "~" | "yes" | "no" | "on" | "off" + ) { return true; } s.chars().any(|c| !is_safe_yaml_char(c)) @@ -287,13 +290,14 @@ pub(crate) fn parse_schema(body: &str) -> Result { for t in raw.tables { let mut columns: Vec = Vec::with_capacity(t.columns.len()); for c in t.columns { - let user_type = c.user_type.parse::().map_err(|_| { - YamlError::UnknownType { + let user_type = c + .user_type + .parse::() + .map_err(|_| YamlError::UnknownType { table: t.name.clone(), column: c.name.clone(), raw: c.user_type.clone(), - } - })?; + })?; columns.push(ColumnSchema { name: c.name, user_type, @@ -308,7 +312,11 @@ pub(crate) fn parse_schema(body: &str) -> Result { primary_key: t.primary_key, columns, unique_constraints: t.unique_constraints, - check_constraints: t.check_constraints.into_iter().map(TableCheck::from).collect(), + check_constraints: t + .check_constraints + .into_iter() + .map(TableCheck::from) + .collect(), }); } let mut relationships: Vec = Vec::with_capacity(raw.relationships.len()); @@ -381,10 +389,7 @@ pub(crate) enum YamlError { impl std::fmt::Display for YamlError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - Self::Syntax(msg) => f.write_str(&crate::t!( - "persistence.yaml.syntax", - detail = msg, - )), + Self::Syntax(msg) => f.write_str(&crate::t!("persistence.yaml.syntax", detail = msg,)), Self::UnsupportedVersion(v) => f.write_str(&crate::t!( "persistence.yaml.unsupported_version", version = v, @@ -395,10 +400,9 @@ impl std::fmt::Display for YamlError { column = column, raw = raw, )), - Self::UnknownAction(raw) => f.write_str(&crate::t!( - "persistence.yaml.unknown_action", - raw = raw, - )), + Self::UnknownAction(raw) => { + f.write_str(&crate::t!("persistence.yaml.unknown_action", raw = raw,)) + } } } } @@ -545,8 +549,22 @@ mod tests { name: "Customers".to_string(), primary_key: vec!["id".to_string()], columns: vec![ - ColumnSchema { name: "id".to_string(), user_type: Type::Serial, unique: false, not_null: false, default: None, check: None }, - ColumnSchema { name: "Name".to_string(), user_type: Type::Text, unique: false, not_null: false, default: None, check: None }, + ColumnSchema { + name: "id".to_string(), + user_type: Type::Serial, + unique: false, + not_null: false, + default: None, + check: None, + }, + ColumnSchema { + name: "Name".to_string(), + user_type: Type::Text, + unique: false, + not_null: false, + default: None, + check: None, + }, ], unique_constraints: Vec::new(), check_constraints: Vec::new(), @@ -555,8 +573,22 @@ mod tests { name: "Orders".to_string(), primary_key: vec!["id".to_string()], columns: vec![ - ColumnSchema { name: "id".to_string(), user_type: Type::Serial, unique: false, not_null: false, default: None, check: None }, - ColumnSchema { name: "CustId".to_string(), user_type: Type::Int, unique: false, not_null: false, default: None, check: None }, + ColumnSchema { + name: "id".to_string(), + user_type: Type::Serial, + unique: false, + not_null: false, + default: None, + check: None, + }, + ColumnSchema { + name: "CustId".to_string(), + user_type: Type::Int, + unique: false, + not_null: false, + default: None, + check: None, + }, ], unique_constraints: Vec::new(), check_constraints: Vec::new(), @@ -798,15 +830,33 @@ indexes: name: "T".to_string(), primary_key: vec![], columns: vec![ - ColumnSchema { name: "a".to_string(), user_type: Type::Int, unique: false, not_null: false, default: None, check: None }, - ColumnSchema { name: "b".to_string(), user_type: Type::Int, unique: false, not_null: false, default: None, check: None }, - ColumnSchema { name: "c".to_string(), user_type: Type::Int, unique: false, not_null: false, default: None, check: None }, + ColumnSchema { + name: "a".to_string(), + user_type: Type::Int, + unique: false, + not_null: false, + default: None, + check: None, + }, + ColumnSchema { + name: "b".to_string(), + user_type: Type::Int, + unique: false, + not_null: false, + default: None, + check: None, + }, + ColumnSchema { + name: "c".to_string(), + user_type: Type::Int, + unique: false, + not_null: false, + default: None, + check: None, + }, ], unique_constraints: vec![vec!["a".to_string(), "b".to_string()]], - check_constraints: vec![ - TableCheck::unnamed("a < b"), - TableCheck::unnamed("b < c"), - ], + check_constraints: vec![TableCheck::unnamed("a < b"), TableCheck::unnamed("b < c")], }], relationships: vec![], indexes: vec![], @@ -830,12 +880,29 @@ indexes: name: "T".to_string(), primary_key: vec!["id".to_string()], columns: vec![ - ColumnSchema { name: "id".to_string(), user_type: Type::Int, unique: false, not_null: false, default: None, check: None }, - ColumnSchema { name: "qty".to_string(), user_type: Type::Int, unique: false, not_null: false, default: None, check: None }, + ColumnSchema { + name: "id".to_string(), + user_type: Type::Int, + unique: false, + not_null: false, + default: None, + check: None, + }, + ColumnSchema { + name: "qty".to_string(), + user_type: Type::Int, + unique: false, + not_null: false, + default: None, + check: None, + }, ], unique_constraints: vec![], check_constraints: vec![ - TableCheck { name: Some("qty_positive".to_string()), expr: "qty >= 0".to_string() }, + TableCheck { + name: Some("qty_positive".to_string()), + expr: "qty >= 0".to_string(), + }, TableCheck::unnamed("qty < 1000"), ], }], @@ -844,7 +911,10 @@ indexes: }; let body = serialize_schema(&snap); let parsed = parse_schema(&body).expect("parse schema"); - assert_eq!(parsed, snap, "named + unnamed table-CHECKs survive the yaml round-trip"); + assert_eq!( + parsed, snap, + "named + unnamed table-CHECKs survive the yaml round-trip" + ); } #[test] @@ -968,8 +1038,22 @@ relationships: name: "Items".to_string(), primary_key: vec!["a".to_string(), "b".to_string()], columns: vec![ - ColumnSchema { name: "a".to_string(), user_type: Type::Int, unique: false, not_null: false, default: None, check: None }, - ColumnSchema { name: "b".to_string(), user_type: Type::Int, unique: false, not_null: false, default: None, check: None }, + ColumnSchema { + name: "a".to_string(), + user_type: Type::Int, + unique: false, + not_null: false, + default: None, + check: None, + }, + ColumnSchema { + name: "b".to_string(), + user_type: Type::Int, + unique: false, + not_null: false, + default: None, + check: None, + }, ], unique_constraints: Vec::new(), check_constraints: Vec::new(), @@ -1019,12 +1103,10 @@ relationships: let absent = "version: 1\nproject:\n created_at: x\ntables: []\n"; assert_eq!(parse_stored_mode(absent), None); - let explicit_simple = - "version: 1\nproject:\n created_at: x\n mode: simple\ntables: []\n"; + let explicit_simple = "version: 1\nproject:\n created_at: x\n mode: simple\ntables: []\n"; assert_eq!(parse_stored_mode(explicit_simple), Some(Mode::Simple)); - let advanced = - "version: 1\nproject:\n created_at: x\n mode: advanced\ntables: []\n"; + let advanced = "version: 1\nproject:\n created_at: x\n mode: advanced\ntables: []\n"; assert_eq!(parse_stored_mode(advanced), Some(Mode::Advanced)); } diff --git a/src/project/lock.rs b/src/project/lock.rs index 958972c..138a084 100644 --- a/src/project/lock.rs +++ b/src/project/lock.rs @@ -170,7 +170,10 @@ fn local_hostname() -> String { /// Uses `sysinfo` to query the OS process table. fn pid_is_alive(pid: u32) -> bool { let mut sys = System::new(); - sys.refresh_processes(sysinfo::ProcessesToUpdate::Some(&[Pid::from_u32(pid)]), true); + sys.refresh_processes( + sysinfo::ProcessesToUpdate::Some(&[Pid::from_u32(pid)]), + true, + ); sys.process(Pid::from_u32(pid)).is_some() } @@ -211,7 +214,10 @@ mod tests { // The first lock writes our own PID; a second attempt // should refuse because the PID is alive on this host. let err = Lock::acquire(dir.path()).expect_err("should refuse second acquisition"); - assert!(matches!(err, LockError::AlreadyHeld { .. }), "unexpected: {err:?}"); + assert!( + matches!(err, LockError::AlreadyHeld { .. }), + "unexpected: {err:?}" + ); } #[test] diff --git a/src/project/mod.rs b/src/project/mod.rs index 98d5436..d007f6e 100644 --- a/src/project/mod.rs +++ b/src/project/mod.rs @@ -78,10 +78,7 @@ pub fn read_last_project(data_root: &Path) -> std::io::Result> { /// a moved/deleted directory is the kind of error `--resume` /// is supposed to surface clearly, not paper over by /// resolving symlinks at write time. -pub fn write_last_project( - data_root: &Path, - project_path: &Path, -) -> std::io::Result<()> { +pub fn write_last_project(data_root: &Path, project_path: &Path) -> std::io::Result<()> { fs::create_dir_all(data_root)?; let final_path = data_root.join(LAST_PROJECT_FILE); let tmp_path = data_root.join(format!("{LAST_PROJECT_FILE}.tmp")); @@ -108,9 +105,8 @@ pub fn resolve_data_root(override_dir: Option<&Path>) -> Result) -> std::fmt::Result { match self { - Self::DataRootUnavailable => { - f.write_str(&crate::t!("project.data_root_unavailable")) + Self::DataRootUnavailable => f.write_str(&crate::t!("project.data_root_unavailable")), + Self::PathNotFound(p) => { + f.write_str(&crate::t!("project.path_not_found", path = p.display(),)) + } + Self::NotAProject(p) => { + f.write_str(&crate::t!("project.not_a_project", path = p.display(),)) + } + Self::AlreadyExists(p) => { + f.write_str(&crate::t!("project.already_exists", path = p.display(),)) } - Self::PathNotFound(p) => f.write_str(&crate::t!( - "project.path_not_found", - path = p.display(), - )), - Self::NotAProject(p) => f.write_str(&crate::t!( - "project.not_a_project", - path = p.display(), - )), - Self::AlreadyExists(p) => f.write_str(&crate::t!( - "project.already_exists", - path = p.display(), - )), Self::Io { path, source } => f.write_str(&crate::t!( "project.io", path = path.display(), @@ -609,11 +600,10 @@ pub fn safely_delete_temp_project( // 2. Canonicalize for the containment check. We do this // only after the symlink-at-top check so we can't be // tricked by a top-level symlink. - let project_canon = - fs::canonicalize(project_path).map_err(|source| SafeDeleteError::Io { - path: project_path.to_path_buf(), - source, - })?; + let project_canon = fs::canonicalize(project_path).map_err(|source| SafeDeleteError::Io { + path: project_path.to_path_buf(), + source, + })?; // 3. Containment: canonical path must be inside the // canonical data-root projects dir. @@ -848,7 +838,10 @@ mod tests { assert!(gi.contains("/playground.db")); assert!(gi.contains("/.rdbms-playground.lock")); assert!(gi.contains("/.snapshots/"), "undo ring should be ignored"); - assert!(!gi.contains("history.log"), "history.log should NOT be ignored"); + assert!( + !gi.contains("history.log"), + "history.log should NOT be ignored" + ); } #[test] @@ -890,7 +883,10 @@ mod tests { let target = tmp.path().join("MyProject"); fs::create_dir(&target).unwrap(); let err = Project::create_named(&target).expect_err("must refuse"); - assert!(matches!(err, ProjectError::AlreadyExists(_)), "got: {err:?}"); + assert!( + matches!(err, ProjectError::AlreadyExists(_)), + "got: {err:?}" + ); } #[test] @@ -962,7 +958,10 @@ mod tests { ) .unwrap(); let read_back = read_last_project(tmp.path()).unwrap(); - assert_eq!(read_back, Some(std::path::PathBuf::from("/tmp/some/project"))); + assert_eq!( + read_back, + Some(std::path::PathBuf::from("/tmp/some/project")) + ); } #[test] diff --git a/src/project/naming.rs b/src/project/naming.rs index 1f4e195..53b2eb9 100644 --- a/src/project/naming.rs +++ b/src/project/naming.rs @@ -17,8 +17,8 @@ use std::path::Path; -use rand::seq::IndexedRandom; use rand::Rng; +use rand::seq::IndexedRandom; const WORDLIST: &str = include_str!("wordlist.txt"); const MAX_COLLISION_RETRIES: usize = 100; @@ -41,10 +41,9 @@ pub enum NamingError { impl std::fmt::Display for NamingError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - Self::WordlistTooSmall(n) => f.write_str(&crate::t!( - "project.naming.wordlist_too_small", - count = n, - )), + Self::WordlistTooSmall(n) => { + f.write_str(&crate::t!("project.naming.wordlist_too_small", count = n,)) + } Self::TooManyCollisions(n) => f.write_str(&crate::t!( "project.naming.too_many_collisions", attempts = n, @@ -189,10 +188,9 @@ impl std::fmt::Display for UserNameError { match self { Self::Empty => f.write_str(&crate::t!("project.user_name.empty")), Self::LeadingDot => f.write_str(&crate::t!("project.user_name.leading_dot")), - Self::InvalidChar(c) => f.write_str(&crate::t!( - "project.user_name.invalid_char", - ch = c, - )), + Self::InvalidChar(c) => { + f.write_str(&crate::t!("project.user_name.invalid_char", ch = c,)) + } } } } @@ -209,14 +207,22 @@ mod tests { #[test] fn wordlist_has_enough_entries() { let pool = words(); - assert!(pool.len() >= 100, "wordlist suspiciously small: {} entries", pool.len()); + assert!( + pool.len() >= 100, + "wordlist suspiciously small: {} entries", + pool.len() + ); } #[test] fn wordlist_has_no_duplicates() { let pool = words(); let unique: std::collections::HashSet<_> = pool.iter().collect(); - assert_eq!(unique.len(), pool.len(), "wordlist contains duplicate entries"); + assert_eq!( + unique.len(), + pool.len(), + "wordlist contains duplicate entries" + ); } #[test] @@ -290,7 +296,7 @@ mod tests { assert!(!is_temp_dirname("MyOrders")); assert!(!is_temp_dirname("term_planner")); assert!(!is_temp_dirname("20260507-water-buffalo-skating")); // no marker - assert!(!is_temp_dirname("temp-project")); // no brackets + assert!(!is_temp_dirname("temp-project")); // no brackets } #[test] @@ -301,9 +307,18 @@ mod tests { assert!(validate_user_name("project.v2").is_ok()); assert_eq!(validate_user_name(""), Err(UserNameError::Empty)); - assert_eq!(validate_user_name(".hidden"), Err(UserNameError::LeadingDot)); - assert!(matches!(validate_user_name("a/b"), Err(UserNameError::InvalidChar('/')))); - assert!(matches!(validate_user_name("a b"), Err(UserNameError::InvalidChar(' ')))); + assert_eq!( + validate_user_name(".hidden"), + Err(UserNameError::LeadingDot) + ); + assert!(matches!( + validate_user_name("a/b"), + Err(UserNameError::InvalidChar('/')) + )); + assert!(matches!( + validate_user_name("a b"), + Err(UserNameError::InvalidChar(' ')) + )); } fn tempdir() -> tempfile::TempDir { diff --git a/src/project/prettifier.rs b/src/project/prettifier.rs index 31854b5..6f92d23 100644 --- a/src/project/prettifier.rs +++ b/src/project/prettifier.rs @@ -129,7 +129,10 @@ mod tests { #[test] fn strips_date_prefix_from_temp_project_names() { - assert_eq!(prettify("20260507-water-buffalo-skating"), "Water Buffalo Skating"); + assert_eq!( + prettify("20260507-water-buffalo-skating"), + "Water Buffalo Skating" + ); } #[test] @@ -205,6 +208,9 @@ mod tests { #[test] fn handles_mixed_separators_and_case() { - assert_eq!(prettify("MyTeam_lessonPlan-2026"), "My Team Lesson Plan 2026"); + assert_eq!( + prettify("MyTeam_lessonPlan-2026"), + "My Team Lesson Plan 2026" + ); } } diff --git a/src/runtime.rs b/src/runtime.rs index ca17a3f..41d887f 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -36,8 +36,8 @@ use crate::db::{ use crate::dsl::command::{ Constraint, ConstraintKind, IndexSelector, RelationshipSelector, TableConstraint, }; -use crate::dsl::{AlterTableAction, ChangeColumnMode, Command, ColumnSpec}; use crate::dsl::walker::Severity; +use crate::dsl::{AlterTableAction, ChangeColumnMode, ColumnSpec, Command}; use crate::event::AppEvent; use crate::project::{ Project, ProjectKind, copy_project, list_projects, open_or_create, projects_dir, @@ -130,8 +130,7 @@ pub async fn run(args: Args) -> Result<()> { // to it for `new` (creates a temp) and `load` (lists // projects). We can't easily recover this from the // Project alone, so we keep it ourselves. - let data_root = resolve_data_root(args.data_dir.as_deref()) - .context("resolve data root")?; + let data_root = resolve_data_root(args.data_dir.as_deref()).context("resolve data root")?; // Resolve the initial project path: --resume reads it from // /last_project; otherwise an explicit positional @@ -143,17 +142,12 @@ pub async fn run(args: Args) -> Result<()> { // terminal so the message lands directly in the user's // shell. let initial_path: Option = if args.resume { - match read_last_project(&data_root) - .context("read last_project")? - { + match read_last_project(&data_root).context("read last_project")? { Some(p) if p.exists() => Some(p), Some(p) => { eprintln!( "rdbms-playground: {}", - crate::t!( - "project.resume_recorded_missing", - path = p.display(), - ), + crate::t!("project.resume_recorded_missing", path = p.display(),), ); return Ok(()); } @@ -488,19 +482,15 @@ async fn run_loop( // Best-effort — a failure to record a failure must // never escalate a user error into a fatal, so the // result is logged and ignored. - if let Err(e) = crate::persistence::Persistence::new( - session.project().path().to_path_buf(), - ) - .append_history_failure(&source, advanced) + if let Err(e) = + crate::persistence::Persistence::new(session.project().path().to_path_buf()) + .append_history_failure(&source, advanced) { tracing::warn!(error = %e, "failed to journal err record (ignored)"); } } Action::PrepareRebuild => { - spawn_prepare_rebuild( - session.project().path().to_path_buf(), - event_tx.clone(), - ); + spawn_prepare_rebuild(session.project().path().to_path_buf(), event_tx.clone()); } Action::Rebuild { source } => { spawn_rebuild( @@ -671,8 +661,8 @@ async fn run_loop( // mutually exclusive (one needs an unmodified temp, the // other anything else). let project_at_quit = session.project.as_ref(); - let cleanup_on_quit: Option = project_at_quit - .and_then(|p| p.is_unmodified_temp().then(|| p.path().to_path_buf())); + let cleanup_on_quit: Option = + project_at_quit.and_then(|p| p.is_unmodified_temp().then(|| p.path().to_path_buf())); let resume_target_on_quit: Option = project_at_quit .filter(|p| !p.is_unmodified_temp()) .map(|p| p.path().to_path_buf()); @@ -831,7 +821,10 @@ async fn perform_switch( Some(p) } SwitchRequest::NewTemp => None, - SwitchRequest::Import { zip_path, as_target } => { + SwitchRequest::Import { + zip_path, + as_target, + } => { if !zip_path.exists() { return Err(crate::t!( "project.import_zip_missing", @@ -840,8 +833,7 @@ async fn perform_switch( } // Validate the zip up front so we don't drop the // current project for an unimportable file. - let inspection = crate::archive::inspect_zip(zip_path) - .map_err(|e| e.to_string())?; + let inspection = crate::archive::inspect_zip(zip_path).map_err(|e| e.to_string())?; let resolved = resolve_import_destination( as_target.as_deref(), &inspection.top_folder, @@ -856,16 +848,19 @@ async fn perform_switch( // state matches the in-memory db). if let SwitchRequest::SaveAs { .. } = &req { let src = session.project().path().to_path_buf(); - let dst = resolved_target.as_ref().expect("SaveAs has resolved target"); + let dst = resolved_target + .as_ref() + .expect("SaveAs has resolved target"); copy_project(&src, dst).map_err(|e| e.to_string())?; } // For Import: extract the zip into the resolved target. // We do this *before* dropping the current project so // a failure here leaves the user where they were. if let SwitchRequest::Import { zip_path, .. } = &req { - let dst = resolved_target.as_ref().expect("Import has resolved target"); - let inspection = crate::archive::inspect_zip(zip_path) - .map_err(|e| e.to_string())?; + let dst = resolved_target + .as_ref() + .expect("Import has resolved target"); + let inspection = crate::archive::inspect_zip(zip_path).map_err(|e| e.to_string())?; crate::archive::extract_into(zip_path, dst, &inspection.top_folder) .map_err(|e| e.to_string())?; } @@ -874,10 +869,10 @@ async fn perform_switch( // we drop it: if it was an unmodified empty temp, we // delete its directory after the switch so the data dir // doesn't accumulate empty scratch projects. - let outgoing_cleanup_path: Option = - session.project.as_ref().and_then(|p| { - p.is_unmodified_temp().then(|| p.path().to_path_buf()) - }); + let outgoing_cleanup_path: Option = session + .project + .as_ref() + .and_then(|p| p.is_unmodified_temp().then(|| p.path().to_path_buf())); // Drop current project + database BEFORE opening the new // ones, releasing the old lock and stopping the old @@ -954,9 +949,7 @@ async fn perform_switch( let new_database = Database::open_with_persistence_and_undo(&db_path, persistence, undo_enabled) .map_err(|e| e.to_string())?; - if !db_existed - && let Err(e) = new_database.rebuild_from_text(new_path.clone(), None).await - { + if !db_existed && let Err(e) = new_database.rebuild_from_text(new_path.clone(), None).await { return Err(e.friendly_message()); } @@ -982,9 +975,7 @@ async fn perform_switch( // fresh empty temp (a `new` command), which must not be // recorded (see the gate in `run()`). Write failures are // non-fatal. - if new_worth_recording - && let Err(e) = write_last_project(&session.data_root, &new_path) - { + if new_worth_recording && let Err(e) = write_last_project(&session.data_root, &new_path) { tracing::warn!(error = %e, "could not update last_project after switch"); } @@ -1045,8 +1036,8 @@ fn spawn_export( event_tx: mpsc::Sender, ) { // `export` app command: journalled simple (ADR-0052). - let _ = crate::persistence::Persistence::new(project_path.clone()) - .append_history(&source, false); + let _ = + crate::persistence::Persistence::new(project_path.clone()).append_history(&source, false); tokio::spawn(async move { let outcome = tokio::task::spawn_blocking(move || { do_export(&project_path, &project_name, &data_root, target.as_deref()) @@ -1081,9 +1072,8 @@ fn do_export( } None => { std::fs::create_dir_all(data_root).map_err(|e| e.to_string())?; - let (filename, _) = - crate::archive::next_export_sequence(data_root, project_name) - .map_err(|e| e.to_string())?; + let (filename, _) = crate::archive::next_export_sequence(data_root, project_name) + .map_err(|e| e.to_string())?; data_root.join(filename) } }; @@ -1143,10 +1133,7 @@ async fn seed_initial_tables(database: &Database, event_tx: &mpsc::Sender, -) { +async fn refresh_schema_cache(database: &Database, event_tx: &mpsc::Sender) { let cache = build_schema_cache(database).await; let _ = event_tx.send(AppEvent::SchemaCacheRefreshed(cache)).await; // ADR-0046 DB2: full relationship records for the sidebar panel. @@ -1234,10 +1221,7 @@ async fn build_schema_cache(database: &Database) -> crate::completion::SchemaCac /// summary that the confirmation modal shows. Runs off the /// event loop so the brief I/O doesn't stall input handling /// even on slow filesystems. -fn spawn_prepare_rebuild( - project_path: std::path::PathBuf, - event_tx: mpsc::Sender, -) { +fn spawn_prepare_rebuild(project_path: std::path::PathBuf, event_tx: mpsc::Sender) { tokio::spawn(async move { let summary = match summarize_project(&project_path) { Ok(s) => s, @@ -1317,9 +1301,7 @@ fn spawn_rebuild( } let summary = summarize_project(&project_path) .unwrap_or_else(|_| "rebuild complete".to_string()); - let _ = event_tx - .send(AppEvent::RebuildSucceeded { summary }) - .await; + let _ = event_tx.send(AppEvent::RebuildSucceeded { summary }).await; // Refresh the table list so the items panel // reflects whatever the rebuild produced. if let Ok(tables) = database.list_tables().await { @@ -1462,12 +1444,8 @@ fn spawn_dsl_dispatch( } let event = match outcome { Ok(CommandOutcome::Schema(description)) => { - let schema_echo = build_schema_echo( - &command, - submission_mode, - description.as_ref(), - &lookups, - ); + let schema_echo = + build_schema_echo(&command, submission_mode, description.as_ref(), &lookups); AppEvent::DslSucceeded { command: command.clone(), description, @@ -1484,12 +1462,10 @@ fn spawn_dsl_dispatch( Ok(CommandOutcome::SchemaDropIndexSkipped) => AppEvent::DslDropIndexSkipped { command: command.clone(), }, - Ok(CommandOutcome::SchemaCreateIndexSkipped(name)) => { - AppEvent::DslCreateIndexSkipped { - command: command.clone(), - name, - } - } + Ok(CommandOutcome::SchemaCreateIndexSkipped(name)) => AppEvent::DslCreateIndexSkipped { + command: command.clone(), + name, + }, Ok(CommandOutcome::Query(data)) => { // ADR-0038: `show data` is the only DSL-form query that // echoes; its limited form orders by the table's primary @@ -1507,12 +1483,10 @@ fn spawn_dsl_dispatch( command: command.clone(), lines, }, - Ok(CommandOutcome::ShowRelationship(data)) => { - AppEvent::DslShowRelationshipSucceeded { - command: command.clone(), - data: data.map(|b| *b), - } - } + Ok(CommandOutcome::ShowRelationship(data)) => AppEvent::DslShowRelationshipSucceeded { + command: command.clone(), + data: data.map(|b| *b), + }, Ok(CommandOutcome::QueryPlan(plan)) => AppEvent::DslExplainSucceeded { command: command.clone(), plan, @@ -1568,11 +1542,8 @@ fn spawn_dsl_dispatch( // the covering indexes the rebuild removed — Bucket B // category 2, ADR-0038 §7 Slice 2b). Non-cascade falls // through to the pre-execution `echo` from `echo_for`. - let cascade_echo = build_drop_column_cascade_echo( - &command, - submission_mode, - &result, - ); + let cascade_echo = + build_drop_column_cascade_echo(&command, submission_mode, &result); AppEvent::DslDropColumnSucceeded { command: command.clone(), result, @@ -1931,12 +1902,14 @@ fn build_schema_echo( )]) } } - Command::DropRelationship { .. } => lookups - .drop_relationship - .as_ref() - .map(|(name, child_table)| { - vec![crate::echo::render_drop_relationship(name, child_table)] - }), + Command::DropRelationship { .. } => { + lookups + .drop_relationship + .as_ref() + .map(|(name, child_table)| { + vec![crate::echo::render_drop_relationship(name, child_table)] + }) + } // `create m:n relationship` (ADR-0045): the resolved junction // columns/FKs only exist on the post-exec description, so the // teaching echo is rendered from it (not `command_to_sql`). @@ -1946,14 +1919,29 @@ fn build_schema_echo( .iter() .filter_map(|c| c.user_type.map(|ty| (c.name.clone(), ty))) .collect(); - let primary_key: Vec = - desc.columns.iter().filter(|c| c.primary_key).map(|c| c.name.clone()).collect(); + let primary_key: Vec = desc + .columns + .iter() + .filter(|c| c.primary_key) + .map(|c| c.name.clone()) + .collect(); let foreign_keys: Vec<(Vec, String, Vec)> = desc .outbound_relationships .iter() - .map(|r| (r.local_columns.clone(), r.other_table.clone(), r.other_columns.clone())) + .map(|r| { + ( + r.local_columns.clone(), + r.other_table.clone(), + r.other_columns.clone(), + ) + }) .collect(); - vec![crate::echo::render_create_m2n(&desc.name, &columns, &primary_key, &foreign_keys)] + vec![crate::echo::render_create_m2n( + &desc.name, + &columns, + &primary_key, + &foreign_keys, + )] }), // Everything else (Bucket A pure-Command, plus the no-echo Bucket C // variants like `Sql*` / `ShowTable`) routes through the existing @@ -2103,10 +2091,7 @@ async fn enrich_unique_violation( facts } -fn enrich_not_null_violation( - command: &Command, - message: &str, -) -> crate::friendly::FailureContext { +fn enrich_not_null_violation(command: &Command, message: &str) -> crate::friendly::FailureContext { let mut facts = crate::friendly::FailureContext::default(); let Some((table, column)) = parse_qualified_target(message) else { return facts; @@ -2133,9 +2118,7 @@ async fn enrich_fk_violation( // schema-aware lookup so natural-order multi-value // INSERT (which `user_value_for_column` alone can't // resolve) gets handled too. - let Ok((outbound, _)) = - database.read_relationships(table.clone()).await - else { + let Ok((outbound, _)) = database.read_relationships(table.clone()).await else { return facts; }; facts.table = Some(table.clone()); @@ -2173,8 +2156,7 @@ async fn enrich_fk_violation( // children reference). Check inbound as a fallback. if facts.parent_table.is_none() && matches!(command, Command::Update { .. }) - && let Ok((_, inbound)) = - database.read_relationships(table.clone()).await + && let Ok((_, inbound)) = database.read_relationships(table.clone()).await && let Some(rel) = inbound.first() { facts.child_table = Some(rel.other_table.clone()); @@ -2184,9 +2166,7 @@ async fn enrich_fk_violation( // Parent-side: inbound FK lookup. Surface a child // table that still references the row(s) being // deleted. - let Ok((_, inbound)) = - database.read_relationships(table.clone()).await - else { + let Ok((_, inbound)) = database.read_relationships(table.clone()).await else { return facts; }; facts.table = Some(table.clone()); @@ -2271,10 +2251,7 @@ async fn user_value_for_column_with_schema( .. } = command { - let desc = database - .describe_table(table.to_string()) - .await - .ok()?; + let desc = database.describe_table(table.to_string()).await.ok()?; // Build the natural-order column list the same way // `do_insert` does: filter out serial / shortid columns // because the engine auto-fills them and the user's @@ -2285,8 +2262,7 @@ async fn user_value_for_column_with_schema( .filter(|c| { !matches!( c.user_type, - Some(crate::dsl::Type::Serial) - | Some(crate::dsl::Type::ShortId) + Some(crate::dsl::Type::Serial) | Some(crate::dsl::Type::ShortId) ) }) .map(|c| c.name.as_str()) @@ -2310,10 +2286,7 @@ async fn user_value_for_column_with_schema( && listed_columns.is_empty() && literal_rows.len() == 1 { - let desc = database - .describe_table(table.to_string()) - .await - .ok()?; + let desc = database.describe_table(table.to_string()).await.ok()?; let idx = desc.columns.iter().position(|c| c.name == column)?; return literal_rows[0].get(idx).cloned().flatten(); } @@ -2323,16 +2296,12 @@ async fn user_value_for_column_with_schema( /// Render a `DataResult` as a `DiagnosticTable` for the /// friendly-error layer's bordered renderer (ADR-0019 §7, /// reusing ADR-0017 §7's renderer). -fn diagnostic_from_data_result( - data: &DataResult, -) -> crate::friendly::DiagnosticTable { - use crate::output_render::{numeric_alignment_for, Alignment}; +fn diagnostic_from_data_result(data: &DataResult) -> crate::friendly::DiagnosticTable { + use crate::output_render::{Alignment, numeric_alignment_for}; let alignments: Vec = data .column_types .iter() - .map(|t| { - t.map_or(Alignment::Left, numeric_alignment_for) - }) + .map(|t| t.map_or(Alignment::Left, numeric_alignment_for)) .collect(); let rows: Vec> = data .rows @@ -2543,9 +2512,7 @@ pub async fn run_replay( // command, which was skipped above) — report it with the line // number and stop. let schema = build_schema_cache(database).await; - let command = match crate::dsl::parser::parse_command_with_schema( - &command_text, &schema, - ) { + let command = match crate::dsl::parser::parse_command_with_schema(&command_text, &schema) { Ok(c) => c, Err(e) => { events.push(AppEvent::ReplayFailed { @@ -2566,8 +2533,7 @@ pub async fn run_replay( // Retain a clone for failure enrichment (the command is moved into // dispatch). ADR-0035 Amendment 1, F2 follow-up. let command_for_ctx = command.clone(); - let outcome = - execute_command_typed(database, command, command_text.clone()).await; + let outcome = execute_command_typed(database, command, command_text.clone()).await; match outcome { Ok(_) => { // ADR-0052: journal the replayed line at the dispatch @@ -2873,7 +2839,10 @@ async fn execute_command_typed( .drop_constraint(table, column, ConstraintKind::NotNull, src) .await .map(|d| CommandOutcome::Schema(Some(d))), - AlterTableAction::SetColumnDefault { column, default_sql } => database + AlterTableAction::SetColumnDefault { + column, + default_sql, + } => database .set_column_default(table, column, default_sql, src) .await .map(|d| CommandOutcome::Schema(Some(d))), @@ -2989,10 +2958,7 @@ async fn execute_command_typed( // A SQL `SELECT` (advanced mode; ADR-0030 §6, ADR-0031). // The grammar walker has already validated `sql` is in // the supported subset; the worker runs it as text. - Command::Select { sql } => database - .run_select(sql) - .await - .map(CommandOutcome::Query), + Command::Select { sql } => database.run_select(sql).await.map(CommandOutcome::Query), // A SQL `INSERT` (advanced mode; ADR-0033 §1). Grammar-as- // text: the worker runs the validated `sql` and re-persists // the parsed `target_table`'s CSV. Reuses the DSL insert @@ -3112,12 +3078,9 @@ fn setup_terminal() -> Result>> { Ok(terminal) } -fn teardown_terminal( - terminal: &mut Terminal>, -) -> Result<()> { +fn teardown_terminal(terminal: &mut Terminal>) -> Result<()> { disable_raw_mode().context("disable raw mode")?; - execute!(terminal.backend_mut(), LeaveAlternateScreen) - .context("leave alternate screen")?; + execute!(terminal.backend_mut(), LeaveAlternateScreen).context("leave alternate screen")?; terminal.show_cursor().context("show cursor")?; Ok(()) } @@ -3257,7 +3220,9 @@ mod tests { // Limited → ORDER BY the resolved primary key. assert_eq!( super::build_show_data_echo(&db, &limited, EffectiveMode::AdvancedPersistent).await, - Some(vec!["SELECT * FROM Customers ORDER BY id LIMIT 5".to_string()]), + Some(vec![ + "SELECT * FROM Customers ORDER BY id LIMIT 5".to_string() + ]), ); // Simple mode → silent, gated before any lookup. assert_eq!( @@ -3288,10 +3253,10 @@ mod tests { async fn bucket_b_resolved_name_echoes_against_real_worker() { use crate::app::EffectiveMode; use crate::db::Database; + use crate::dsl::Command; use crate::dsl::ReferentialAction; use crate::dsl::command::{ColumnSpec, IndexSelector, RelationshipSelector}; use crate::dsl::types::Type; - use crate::dsl::Command; let db = Database::open(":memory:").expect("open in-memory"); db.create_table( @@ -3319,7 +3284,12 @@ mod tests { // --- add index (auto-named) ---------------------------------- let desc_after_add_index = db - .add_index(None, "Customers".to_string(), vec!["Email".to_string()], None) + .add_index( + None, + "Customers".to_string(), + vec!["Email".to_string()], + None, + ) .await .expect("add index"); let add_idx_cmd = Command::AddIndex { @@ -3439,7 +3409,10 @@ mod tests { .await; assert_eq!( endpoints_lookups.drop_relationship, - Some(("Customers_id_to_Orders_CustId".to_string(), "Orders".to_string())), + Some(( + "Customers_id_to_Orders_CustId".to_string(), + "Orders".to_string() + )), "endpoints selector resolves name via child describe", ); @@ -3454,7 +3427,10 @@ mod tests { .await; assert_eq!( named_lookups.drop_relationship, - Some(("Customers_id_to_Orders_CustId".to_string(), "Orders".to_string())), + Some(( + "Customers_id_to_Orders_CustId".to_string(), + "Orders".to_string() + )), "named selector scans user tables to find the child", ); @@ -3487,10 +3463,10 @@ mod tests { async fn bucket_b_multi_statement_echoes_against_real_worker() { use crate::app::EffectiveMode; use crate::db::Database; + use crate::dsl::Command; use crate::dsl::ReferentialAction; use crate::dsl::command::ColumnSpec; use crate::dsl::types::Type; - use crate::dsl::Command; // --- drop column --cascade ----------------------------------- let db = Database::open(":memory:").expect("open in-memory"); @@ -3505,9 +3481,14 @@ mod tests { ) .await .expect("create Customers"); - db.add_index(None, "Customers".to_string(), vec!["Email".to_string()], None) - .await - .expect("index Email"); + db.add_index( + None, + "Customers".to_string(), + vec!["Email".to_string()], + None, + ) + .await + .expect("index Email"); let drop_cmd = Command::DropColumn { table: "Customers".to_string(), @@ -3531,12 +3512,8 @@ mod tests { ); // Simple mode → silent. assert!( - super::build_drop_column_cascade_echo( - &drop_cmd, - EffectiveMode::Simple, - &drop_result, - ) - .is_none(), + super::build_drop_column_cascade_echo(&drop_cmd, EffectiveMode::Simple, &drop_result,) + .is_none(), ); // --- add relationship --create-fk (column newly created) ---- @@ -3673,11 +3650,11 @@ mod tests { // switch (an unmodified temp would be cleaned up, taking its // project.yaml with it). Without the unload persist the // outgoing skeleton carries no `mode:` → `None`. - use super::{handle_project_switch, Session, SwitchRequest}; + use super::{Session, SwitchRequest, handle_project_switch}; use crate::db::Database; use crate::mode::Mode; use crate::persistence::Persistence; - use crate::project::{projects_dir, Project}; + use crate::project::{Project, projects_dir}; use tokio::sync::mpsc; let data_root = tempfile::tempdir().unwrap(); @@ -3686,8 +3663,7 @@ mod tests { let outgoing_path = projects.join("Outgoing"); let outgoing = Project::create_named(&outgoing_path).unwrap(); let db_path = outgoing.db_path(); - let persistence = - Persistence::new(outgoing.path().to_path_buf()).with_mode(Mode::Advanced); + let persistence = Persistence::new(outgoing.path().to_path_buf()).with_mode(Mode::Advanced); let database = Database::open_with_persistence_and_undo(&db_path, persistence, true).unwrap(); let mut session = Session { diff --git a/src/seed/check.rs b/src/seed/check.rs index 1294eb1..ad27f29 100644 --- a/src/seed/check.rs +++ b/src/seed/check.rs @@ -18,7 +18,11 @@ pub fn parse_in_check_values(check: &str, column: &str) -> Option> { return None; } let values = extract_quoted_list(&check[paren_open..])?; - if values.is_empty() { None } else { Some(values) } + if values.is_empty() { + None + } else { + Some(values) + } } const fn is_ident_byte(b: u8) -> bool { @@ -45,8 +49,8 @@ fn find_in_paren(check: &str) -> Option<(usize, usize)> { i += 1; continue; } - let is_in = (b == b'i' || b == b'I') - && bytes.get(i + 1).is_some_and(|n| *n == b'n' || *n == b'N'); + let is_in = + (b == b'i' || b == b'I') && bytes.get(i + 1).is_some_and(|n| *n == b'n' || *n == b'N'); if is_in { let before_ok = i == 0 || !is_ident_byte(bytes[i - 1]); let after = i + 2; diff --git a/src/seed/generators.rs b/src/seed/generators.rs index 7e81f86..9d09be1 100644 --- a/src/seed/generators.rs +++ b/src/seed/generators.rs @@ -81,17 +81,22 @@ pub fn generate_value(generator: &Generator, ty: Type, rng: &mut SeedRng) -> Val Generator::CurrencyAmount => currency_amount(ty, rng), Generator::Age => Value::Number(rng.random_range(18..=80).to_string()), Generator::SmallInt => Value::Number(rng.random_range(1..=100).to_string()), - Generator::YearRecent => { - Value::Number(rng.random_range((REF_YEAR - YEAR_RECENT_SPAN)..=REF_YEAR).to_string()) - } + Generator::YearRecent => Value::Number( + rng.random_range((REF_YEAR - YEAR_RECENT_SPAN)..=REF_YEAR) + .to_string(), + ), Generator::YearBirth => Value::Number( rng.random_range((REF_YEAR - YEAR_BIRTH_MAX_AGE)..=(REF_YEAR - YEAR_BIRTH_MIN_AGE)) .to_string(), ), - Generator::DateRecent => Value::Text(format_date(random_past_date(rng, 0, RECENT_WINDOW_DAYS))), - Generator::DateAdult => { - Value::Text(format_date(random_past_date(rng, ADULT_MIN_DAYS, ADULT_MAX_DAYS))) + Generator::DateRecent => { + Value::Text(format_date(random_past_date(rng, 0, RECENT_WINDOW_DAYS))) } + Generator::DateAdult => Value::Text(format_date(random_past_date( + rng, + ADULT_MIN_DAYS, + ADULT_MAX_DAYS, + ))), Generator::DateTimeRecent => Value::Text(random_recent_datetime(rng)), Generator::Boolean => Value::Bool(rng.random_range(0..2) == 1), Generator::PickFrom(values) if !values.is_empty() => { @@ -232,8 +237,7 @@ fn random_datetime_between( } else { rng.random_range(hi_s..=lo_s) }; - let dt = chrono::DateTime::from_timestamp(secs, 0) - .map_or(lo, |d| d.naive_utc()); + let dt = chrono::DateTime::from_timestamp(secs, 0).map_or(lo, |d| d.naive_utc()); dt.format("%Y-%m-%dT%H:%M:%S").to_string() } @@ -294,20 +298,35 @@ fn currency_amount(ty: Type, rng: &mut SeedRng) -> Value { // — the hand-rolled `product` generator (D9) — const PRODUCT_ADJECTIVES: &[&str] = &[ - "Sleek", "Rustic", "Ergonomic", "Handcrafted", "Refined", "Modern", - "Vintage", "Compact", "Premium", "Lightweight", "Durable", "Elegant", - "Sturdy", "Smooth", "Gorgeous", "Intelligent", "Practical", "Awesome", - "Incredible", "Recycled", + "Sleek", + "Rustic", + "Ergonomic", + "Handcrafted", + "Refined", + "Modern", + "Vintage", + "Compact", + "Premium", + "Lightweight", + "Durable", + "Elegant", + "Sturdy", + "Smooth", + "Gorgeous", + "Intelligent", + "Practical", + "Awesome", + "Incredible", + "Recycled", ]; const PRODUCT_MATERIALS: &[&str] = &[ - "Wooden", "Copper", "Granite", "Cotton", "Steel", "Leather", "Bamboo", - "Plastic", "Ceramic", "Glass", "Concrete", "Rubber", "Bronze", "Marble", - "Linen", "Silk", "Aluminum", "Wool", "Gold", "Carbon", + "Wooden", "Copper", "Granite", "Cotton", "Steel", "Leather", "Bamboo", "Plastic", "Ceramic", + "Glass", "Concrete", "Rubber", "Bronze", "Marble", "Linen", "Silk", "Aluminum", "Wool", "Gold", + "Carbon", ]; const PRODUCT_NOUNS: &[&str] = &[ - "Chair", "Lamp", "Table", "Bottle", "Backpack", "Keyboard", "Mug", - "Shoes", "Jacket", "Watch", "Wallet", "Bench", "Hat", "Gloves", - "Towel", "Ball", "Bike", "Knife", "Pillow", "Blanket", + "Chair", "Lamp", "Table", "Bottle", "Backpack", "Keyboard", "Mug", "Shoes", "Jacket", "Watch", + "Wallet", "Bench", "Hat", "Gloves", "Towel", "Ball", "Bike", "Knife", "Pillow", "Blanket", ]; fn product_name(rng: &mut SeedRng) -> String { @@ -396,7 +415,9 @@ mod tests { ] { let v = gen_once(&generator, Type::Text, 3); match v { - Value::Text(s) => assert!(!s.trim().is_empty(), "{generator:?} produced empty text"), + Value::Text(s) => { + assert!(!s.trim().is_empty(), "{generator:?} produced empty text") + } other => panic!("{generator:?} produced non-text {other:?}"), } } @@ -405,18 +426,25 @@ mod tests { #[test] fn email_looks_like_an_email() { let v = gen_once(&Generator::Email, Type::Text, 11); - let Value::Text(s) = v else { panic!("not text") }; + let Value::Text(s) = v else { + panic!("not text") + }; assert!(s.contains('@'), "email should contain @: {s}"); } #[test] fn product_name_is_three_capitalised_words() { let v = gen_once(&Generator::ProductName, Type::Text, 99); - let Value::Text(s) = v else { panic!("not text") }; + let Value::Text(s) = v else { + panic!("not text") + }; let words: Vec<&str> = s.split(' ').collect(); assert_eq!(words.len(), 3, "product name should be 3 words: {s}"); for w in words { - assert!(w.chars().next().unwrap().is_ascii_uppercase(), "word `{w}` not capitalised"); + assert!( + w.chars().next().unwrap().is_ascii_uppercase(), + "word `{w}` not capitalised" + ); } } @@ -429,9 +457,14 @@ mod tests { let latest = reference_date(); for _ in 0..200 { let v = generate_value(&Generator::DateRecent, Type::Date, &mut rng); - let Value::Text(s) = v else { panic!("date not text") }; + let Value::Text(s) = v else { + panic!("date not text") + }; let d = NaiveDate::parse_from_str(&s, "%Y-%m-%d").expect("valid ISO date"); - assert!(d >= earliest && d <= latest, "date {d} outside recent window"); + assert!( + d >= earliest && d <= latest, + "date {d} outside recent window" + ); } } @@ -446,7 +479,9 @@ mod tests { .unwrap(); for _ in 0..200 { let v = generate_value(&Generator::DateAdult, Type::Date, &mut rng); - let Value::Text(s) = v else { panic!("date not text") }; + let Value::Text(s) = v else { + panic!("date not text") + }; let d = NaiveDate::parse_from_str(&s, "%Y-%m-%d").expect("valid ISO date"); assert!(d >= earliest && d <= latest, "dob {d} outside adult window"); } @@ -455,7 +490,9 @@ mod tests { #[test] fn datetime_is_iso_shaped() { let v = gen_once(&Generator::DateTimeRecent, Type::DateTime, 5); - let Value::Text(s) = v else { panic!("not text") }; + let Value::Text(s) = v else { + panic!("not text") + }; assert!(s.contains('T'), "datetime needs a T separator: {s}"); // Parses as a naive datetime. chrono::NaiveDateTime::parse_from_str(&s, "%Y-%m-%dT%H:%M:%S") @@ -467,11 +504,17 @@ mod tests { let Value::Number(int_amt) = gen_once(&Generator::CurrencyAmount, Type::Int, 4) else { panic!("not a number") }; - assert!(!int_amt.contains('.'), "int currency should be whole: {int_amt}"); + assert!( + !int_amt.contains('.'), + "int currency should be whole: {int_amt}" + ); let Value::Number(dec_amt) = gen_once(&Generator::CurrencyAmount, Type::Decimal, 4) else { panic!("not a number") }; - assert!(dec_amt.contains('.'), "decimal currency should have cents: {dec_amt}"); + assert!( + dec_amt.contains('.'), + "decimal currency should have cents: {dec_amt}" + ); } #[test] @@ -494,7 +537,10 @@ mod tests { let Value::Text(s) = generate_value(&generator, Type::Text, &mut rng) else { panic!("not text") }; - assert!(matches!(s.as_str(), "active" | "closed"), "unexpected pick {s}"); + assert!( + matches!(s.as_str(), "active" | "closed"), + "unexpected pick {s}" + ); } } @@ -503,7 +549,10 @@ mod tests { let generator = Generator::PickFrom(vec!["1".into(), "2".into(), "3".into()]); let mut rng = make_rng(Some(6)); let v = generate_value(&generator, Type::Int, &mut rng); - assert!(matches!(v, Value::Number(_)), "numeric pick should be a Number: {v:?}"); + assert!( + matches!(v, Value::Number(_)), + "numeric pick should be a Number: {v:?}" + ); } #[test] @@ -517,7 +566,10 @@ mod tests { panic!("YearRecent must be a Number") }; let n: i32 = s.parse().unwrap(); - assert!((1950..=2025).contains(&n), "YearRecent {n} out of [1950,2025]"); + assert!( + (1950..=2025).contains(&n), + "YearRecent {n} out of [1950,2025]" + ); } for _ in 0..300 { let Value::Number(s) = generate_value(&Generator::YearBirth, Type::Int, &mut rng) @@ -525,7 +577,10 @@ mod tests { panic!("YearBirth must be a Number") }; let n: i32 = s.parse().unwrap(); - assert!((1945..=2007).contains(&n), "YearBirth {n} out of [1945,2007]"); + assert!( + (1945..=2007).contains(&n), + "YearBirth {n} out of [1945,2007]" + ); } } @@ -543,7 +598,10 @@ mod tests { #[test] fn int_range_stays_within_inclusive_bounds() { - let g = Generator::Range { low: "10".into(), high: "20".into() }; + let g = Generator::Range { + low: "10".into(), + high: "20".into(), + }; let mut rng = make_rng(Some(5)); for _ in 0..200 { let Value::Number(s) = generate_value(&g, Type::Int, &mut rng) else { @@ -556,7 +614,10 @@ mod tests { #[test] fn real_range_stays_within_bounds_and_has_cents() { - let g = Generator::Range { low: "1.0".into(), high: "9.0".into() }; + let g = Generator::Range { + low: "1.0".into(), + high: "9.0".into(), + }; let mut rng = make_rng(Some(5)); for _ in 0..200 { let Value::Number(s) = generate_value(&g, Type::Real, &mut rng) else { @@ -588,13 +649,19 @@ mod tests { #[test] fn reversed_bounds_are_tolerated() { - let g = Generator::Range { low: "20".into(), high: "10".into() }; + let g = Generator::Range { + low: "20".into(), + high: "10".into(), + }; let mut rng = make_rng(Some(1)); let Value::Number(s) = generate_value(&g, Type::Int, &mut rng) else { panic!("number") }; let n: i64 = s.parse().unwrap(); - assert!((10..=20).contains(&n), "reversed bounds still produce in-range: {n}"); + assert!( + (10..=20).contains(&n), + "reversed bounds still produce in-range: {n}" + ); } #[test] @@ -603,7 +670,10 @@ mod tests { assert!(range_bounds_reason(Type::Int, "1", "10").is_none()); assert!(range_bounds_reason(Type::Real, "1.5", "9.9").is_none()); assert!(range_bounds_reason(Type::Date, "2023-01-01", "2024-01-01").is_none()); - assert!(range_bounds_reason(Type::DateTime, "2023-01-01T00:00:00", "2024-01-01T00:00:00").is_none()); + assert!( + range_bounds_reason(Type::DateTime, "2023-01-01T00:00:00", "2024-01-01T00:00:00") + .is_none() + ); // Non-numeric bound on a numeric column. assert!(range_bounds_reason(Type::Int, "abc", "10").is_some()); // A range on a text column is meaningless. @@ -623,14 +693,29 @@ mod tests { #[test] fn generic_fallback_matches_each_type() { let mut rng = make_rng(Some(0)); - assert!(matches!(generate_value(&Generator::Generic, Type::Text, &mut rng), Value::Text(_))); - assert!(matches!(generate_value(&Generator::Generic, Type::Int, &mut rng), Value::Number(_))); - assert!(matches!(generate_value(&Generator::Generic, Type::Bool, &mut rng), Value::Bool(_))); - assert!(matches!(generate_value(&Generator::Generic, Type::Blob, &mut rng), Value::Null)); + assert!(matches!( + generate_value(&Generator::Generic, Type::Text, &mut rng), + Value::Text(_) + )); + assert!(matches!( + generate_value(&Generator::Generic, Type::Int, &mut rng), + Value::Number(_) + )); + assert!(matches!( + generate_value(&Generator::Generic, Type::Bool, &mut rng), + Value::Bool(_) + )); + assert!(matches!( + generate_value(&Generator::Generic, Type::Blob, &mut rng), + Value::Null + )); // shortid fallback is a valid base58 id. let Value::Text(sid) = generate_value(&Generator::Generic, Type::ShortId, &mut rng) else { panic!("shortid not text") }; - assert!(crate::dsl::shortid::validate(&sid).is_ok(), "invalid shortid {sid}"); + assert!( + crate::dsl::shortid::validate(&sid).is_ok(), + "invalid shortid {sid}" + ); } } diff --git a/src/seed/heuristics.rs b/src/seed/heuristics.rs index 3162dd2..1701386 100644 --- a/src/seed/heuristics.rs +++ b/src/seed/heuristics.rs @@ -63,8 +63,7 @@ pub fn is_enum_ish(name: &str) -> bool { // `rating` / `stars` were never here. `status` stays — it is // deliberately left to the advisory (no built-in set). const ENUM_TOKENS: &[&str] = &[ - "role", "status", "state", "type", "kind", "category", "level", - "tier", "stage", "gender", + "role", "status", "state", "type", "kind", "category", "level", "tier", "stage", "gender", ]; let toks = tokens(name); toks.iter().any(|t| ENUM_TOKENS.contains(&t.as_str())) @@ -81,9 +80,7 @@ fn match_name_generator(table: &str, toks: &[String], ty: Type) -> Option Option Option Option Option Option Generator { let toks = tokens(table); const PRODUCTY: &[&str] = &[ - "product", "products", "item", "items", "good", "goods", - "merchandise", "catalog", "catalogue", "inventory", "sku", "skus", + "product", + "products", + "item", + "items", + "good", + "goods", + "merchandise", + "catalog", + "catalogue", + "inventory", + "sku", + "skus", ]; const COMPANYISH: &[&str] = &[ - "company", "companies", "vendor", "vendors", "supplier", - "suppliers", "manufacturer", "manufacturers", "brand", "brands", - "organization", "organisation", + "company", + "companies", + "vendor", + "vendors", + "supplier", + "suppliers", + "manufacturer", + "manufacturers", + "brand", + "brands", + "organization", + "organisation", ]; const PERSONISH: &[&str] = &[ - "user", "users", "customer", "customers", "person", "people", - "employee", "employees", "member", "members", "contact", - "contacts", "author", "authors", "student", "students", + "user", + "users", + "customer", + "customers", + "person", + "people", + "employee", + "employees", + "member", + "members", + "contact", + "contacts", + "author", + "authors", + "student", + "students", ]; if has_any(&toks, PRODUCTY) { Generator::ProductName @@ -264,9 +322,8 @@ fn name_by_table_context(table: &str) -> Generator { /// before this guard; this catches structural names. fn is_name_false_positive(toks: &[String]) -> bool { const NON_PERSON: &[&str] = &[ - "file", "table", "host", "domain", "field", "class", "tag", - "event", "path", "col", "column", "db", "schema", "index", "key", - "page", "node", "type", + "file", "table", "host", "domain", "field", "class", "tag", "event", "path", "col", + "column", "db", "schema", "index", "key", "page", "node", "type", ]; has_any(toks, NON_PERSON) && has_any(toks, &["name", "title"]) } @@ -357,9 +414,18 @@ mod tests { #[test] fn person_name_fields_map_to_name_generators() { - assert_eq!(choose("users", "first_name", Type::Text), Generator::FirstName); - assert_eq!(choose("users", "firstName", Type::Text), Generator::FirstName); - assert_eq!(choose("users", "last_name", Type::Text), Generator::LastName); + assert_eq!( + choose("users", "first_name", Type::Text), + Generator::FirstName + ); + assert_eq!( + choose("users", "firstName", Type::Text), + Generator::FirstName + ); + assert_eq!( + choose("users", "last_name", Type::Text), + Generator::LastName + ); assert_eq!(choose("users", "surname", Type::Text), Generator::LastName); } @@ -368,9 +434,15 @@ mod tests { assert_eq!(choose("users", "email", Type::Text), Generator::Email); assert_eq!(choose("users", "work_email", Type::Text), Generator::Email); assert_eq!(choose("users", "username", Type::Text), Generator::Username); - assert_eq!(choose("users", "user_name", Type::Text), Generator::Username); + assert_eq!( + choose("users", "user_name", Type::Text), + Generator::Username + ); assert_eq!(choose("users", "phone", Type::Text), Generator::Phone); - assert_eq!(choose("accounts", "password", Type::Text), Generator::Password); + assert_eq!( + choose("accounts", "password", Type::Text), + Generator::Password + ); } #[test] @@ -386,7 +458,10 @@ mod tests { #[test] fn bare_name_uses_table_context() { // D11 — the same column name resolves differently by table. - assert_eq!(choose("products", "name", Type::Text), Generator::ProductName); + assert_eq!( + choose("products", "name", Type::Text), + Generator::ProductName + ); assert_eq!(choose("items", "title", Type::Text), Generator::ProductName); assert_eq!(choose("users", "name", Type::Text), Generator::FullName); assert_eq!(choose("customers", "name", Type::Text), Generator::FullName); @@ -399,7 +474,10 @@ mod tests { fn name_false_positives_do_not_become_person_names() { // These must NOT resolve to a person/product name. assert_ne!(choose("files", "filename", Type::Text), Generator::FullName); - assert_ne!(choose("meta", "table_name", Type::Text), Generator::FullName); + assert_ne!( + choose("meta", "table_name", Type::Text), + Generator::FullName + ); // They fall through to a generic / non-person generator. assert_eq!(choose("files", "filename", Type::Text), Generator::Generic); } @@ -408,7 +486,10 @@ mod tests { fn numeric_name_heuristics_are_type_gated() { // `price` on a numeric column → currency; on text → falls through. assert_eq!(choose("p", "price", Type::Int), Generator::CurrencyAmount); - assert_eq!(choose("p", "price", Type::Decimal), Generator::CurrencyAmount); + assert_eq!( + choose("p", "price", Type::Decimal), + Generator::CurrencyAmount + ); assert_eq!(choose("p", "price", Type::Text), Generator::Generic); assert_eq!(choose("u", "age", Type::Int), Generator::Age); assert_eq!(choose("o", "quantity", Type::Int), Generator::SmallInt); @@ -425,8 +506,14 @@ mod tests { fn temporal_fields_are_bounded_and_type_gated() { assert_eq!(choose("u", "dob", Type::Date), Generator::DateAdult); assert_eq!(choose("o", "order_date", Type::Date), Generator::DateRecent); - assert_eq!(choose("o", "created_at", Type::DateTime), Generator::DateTimeRecent); - assert_eq!(choose("o", "timestamp", Type::DateTime), Generator::DateTimeRecent); + assert_eq!( + choose("o", "created_at", Type::DateTime), + Generator::DateTimeRecent + ); + assert_eq!( + choose("o", "timestamp", Type::DateTime), + Generator::DateTimeRecent + ); // Wrong type → not a date generator. assert_eq!(choose("o", "order_date", Type::Int), Generator::Generic); } @@ -440,17 +527,32 @@ mod tests { #[test] fn identifier_family_is_unique_sequential() { - assert_eq!(choose("t", "code", Type::Text), Generator::IdentitySequential); - assert_eq!(choose("t", "sku", Type::Text), Generator::IdentitySequential); - assert_eq!(choose("t", "order_number", Type::Int), Generator::IdentitySequential); - assert_eq!(choose("t", "external_id", Type::Int), Generator::IdentitySequential); + assert_eq!( + choose("t", "code", Type::Text), + Generator::IdentitySequential + ); + assert_eq!( + choose("t", "sku", Type::Text), + Generator::IdentitySequential + ); + assert_eq!( + choose("t", "order_number", Type::Int), + Generator::IdentitySequential + ); + assert_eq!( + choose("t", "external_id", Type::Int), + Generator::IdentitySequential + ); } #[test] fn foreign_key_columns_defer_to_executor() { let mut spec = ColumnSpec::plain("user_id", Type::Int); spec.is_foreign_key = true; - assert_eq!(choose_generator("orders", &spec), Generator::ForeignKeySample); + assert_eq!( + choose_generator("orders", &spec), + Generator::ForeignKeySample + ); } #[test] @@ -481,13 +583,28 @@ mod tests { fn year_like_int_columns_map_to_bounded_years() { // Issue #33: `int`-gated year heuristics. `birth`/`born`/`dob` // years pick the birth window; the rest a recent window. - assert_eq!(choose("authors", "birth_year", Type::Int), Generator::YearBirth); - assert_eq!(choose("authors", "birthYear", Type::Int), Generator::YearBirth); + assert_eq!( + choose("authors", "birth_year", Type::Int), + Generator::YearBirth + ); + assert_eq!( + choose("authors", "birthYear", Type::Int), + Generator::YearBirth + ); assert_eq!(choose("u", "year_born", Type::Int), Generator::YearBirth); assert_eq!(choose("books", "year", Type::Int), Generator::YearRecent); - assert_eq!(choose("films", "release_year", Type::Int), Generator::YearRecent); - assert_eq!(choose("books", "published", Type::Int), Generator::YearRecent); - assert_eq!(choose("companies", "founded", Type::Int), Generator::YearRecent); + assert_eq!( + choose("films", "release_year", Type::Int), + Generator::YearRecent + ); + assert_eq!( + choose("books", "published", Type::Int), + Generator::YearRecent + ); + assert_eq!( + choose("companies", "founded", Type::Int), + Generator::YearRecent + ); // Type-gated: a text `year` is not a bounded-year int. assert_eq!(choose("books", "year", Type::Text), Generator::Generic); // `year_count` is a count, not a year — the quantity rule wins. @@ -507,7 +624,12 @@ mod tests { ); assert_eq!( choose("bugs", "severity", Type::Text), - Generator::PickFrom(vec!["low".into(), "medium".into(), "high".into(), "critical".into()]), + Generator::PickFrom(vec![ + "low".into(), + "medium".into(), + "high".into(), + "critical".into() + ]), ); assert_eq!( choose("bugs", "severity", Type::Int), @@ -515,11 +637,23 @@ mod tests { ); assert_eq!( choose("reviews", "rating", Type::Int), - Generator::PickFrom(vec!["1".into(), "2".into(), "3".into(), "4".into(), "5".into()]), + Generator::PickFrom(vec![ + "1".into(), + "2".into(), + "3".into(), + "4".into(), + "5".into() + ]), ); assert_eq!( choose("reviews", "stars", Type::Int), - Generator::PickFrom(vec!["1".into(), "2".into(), "3".into(), "4".into(), "5".into()]), + Generator::PickFrom(vec![ + "1".into(), + "2".into(), + "3".into(), + "4".into(), + "5".into() + ]), ); } @@ -552,7 +686,10 @@ mod tests { #[test] fn unmatched_columns_use_type_based_fallback() { - assert_eq!(choose("t", "some_freeform_field", Type::Text), Generator::Generic); + assert_eq!( + choose("t", "some_freeform_field", Type::Text), + Generator::Generic + ); } #[test] diff --git a/src/seed/mod.rs b/src/seed/mod.rs index 452097b..385efcd 100644 --- a/src/seed/mod.rs +++ b/src/seed/mod.rs @@ -32,7 +32,7 @@ mod vocabulary; pub use check::parse_in_check_values; pub use generators::{generate_value, range_bounds_reason}; pub use heuristics::{choose_generator, is_enum_ish}; -pub use vocabulary::{generator_for_name, is_known_generator_prefix, KNOWN_GENERATORS}; +pub use vocabulary::{KNOWN_GENERATORS, generator_for_name, is_known_generator_prefix}; use rand::rngs::StdRng; use rand::{RngExt, SeedableRng}; @@ -183,7 +183,10 @@ pub enum Generator { /// does not parse for the column type is a friendly error), so /// [`generate_value`] only ever sees parseable bounds; a defensive /// parse failure falls back to type-based generation. - Range { low: String, high: String }, + Range { + low: String, + high: String, + }, /// Type-based fallback (D8) when no name heuristic matches. Generic, } diff --git a/src/theme.rs b/src/theme.rs index 8ffeabb..ef43d8e 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -104,15 +104,15 @@ impl Theme { // remains restful; literals and flags get warm // accent tones; keyword takes a cool accent tone // distinct from the mode-banner blue. - tok_keyword: Color::Rgb(0xC7, 0x92, 0xEA), // muted purple + tok_keyword: Color::Rgb(0xC7, 0x92, 0xEA), // muted purple tok_identifier: Color::Rgb(0x56, 0xB6, 0xC2), // cyan-teal — identifiers are the user's content, deserve a vivid distinct colour - tok_type: Color::Rgb(0xF0, 0x8F, 0xC0), // pink — types sit in the red-purple range, clearly apart from the lavender keyword and teal identifier - tok_number: Color::Rgb(0xF7, 0x8C, 0x6C), // warm orange - tok_string: Color::Rgb(0xC3, 0xE8, 0x8D), // soft green - tok_punct: Color::Rgb(0x8B, 0x90, 0x9A), // == muted - tok_flag: Color::Rgb(0xFF, 0xCB, 0x6B), // amber - tok_error: Color::Rgb(0xFF, 0x6B, 0x6B), // == error - tok_function: Color::Rgb(0x82, 0xCF, 0xFD), // sky blue — cool like keyword but bluer, clearly apart from purple keyword + teal identifier + pink type + tok_type: Color::Rgb(0xF0, 0x8F, 0xC0), // pink — types sit in the red-purple range, clearly apart from the lavender keyword and teal identifier + tok_number: Color::Rgb(0xF7, 0x8C, 0x6C), // warm orange + tok_string: Color::Rgb(0xC3, 0xE8, 0x8D), // soft green + tok_punct: Color::Rgb(0x8B, 0x90, 0x9A), // == muted + tok_flag: Color::Rgb(0xFF, 0xCB, 0x6B), // amber + tok_error: Color::Rgb(0xFF, 0x6B, 0x6B), // == error + tok_function: Color::Rgb(0x82, 0xCF, 0xFD), // sky blue — cool like keyword but bluer, clearly apart from purple keyword + teal identifier + pink type } } @@ -135,15 +135,15 @@ impl Theme { // Light-theme token palette: same intent as dark — // identifier/punct close to fg/muted; warm tones for // literals + flags; cool accent for keyword. - tok_keyword: Color::Rgb(0x6F, 0x42, 0xC1), // royal purple + tok_keyword: Color::Rgb(0x6F, 0x42, 0xC1), // royal purple tok_identifier: Color::Rgb(0x0F, 0x6B, 0x76), // deep teal — same role as dark variant: identifiers stand out - tok_type: Color::Rgb(0xA8, 0x2D, 0x73), // deep magenta — red-purple, distinct from royal-purple keyword + teal identifier - tok_number: Color::Rgb(0xBC, 0x4F, 0x1F), // burnt orange - tok_string: Color::Rgb(0x22, 0x86, 0x3A), // forest green - tok_punct: Color::Rgb(0x60, 0x66, 0x73), // == muted - tok_flag: Color::Rgb(0xB0, 0x88, 0x00), // mustard - tok_error: Color::Rgb(0xC0, 0x39, 0x2B), // == error - tok_function: Color::Rgb(0x1A, 0x5F, 0xB0), // strong blue — cool like keyword but bluer, apart from royal-purple keyword + teal identifier + magenta type + tok_type: Color::Rgb(0xA8, 0x2D, 0x73), // deep magenta — red-purple, distinct from royal-purple keyword + teal identifier + tok_number: Color::Rgb(0xBC, 0x4F, 0x1F), // burnt orange + tok_string: Color::Rgb(0x22, 0x86, 0x3A), // forest green + tok_punct: Color::Rgb(0x60, 0x66, 0x73), // == muted + tok_flag: Color::Rgb(0xB0, 0x88, 0x00), // mustard + tok_error: Color::Rgb(0xC0, 0x39, 0x2B), // == error + tok_function: Color::Rgb(0x1A, 0x5F, 0xB0), // strong blue — cool like keyword but bluer, apart from royal-purple keyword + teal identifier + magenta type } } @@ -192,10 +192,7 @@ mod tests { ("tok_function", t.tok_function), ("warning", t.warning), ] { - assert_ne!( - c, t.bg, - "{name} must contrast against bg in dark theme", - ); + assert_ne!(c, t.bg, "{name} must contrast against bg in dark theme",); } } @@ -212,24 +209,36 @@ mod tests { ("tok_function", t.tok_function), ("warning", t.warning), ] { - assert_ne!( - c, t.bg, - "{name} must contrast against bg in light theme", - ); + assert_ne!(c, t.bg, "{name} must contrast against bg in light theme",); } } #[test] fn highlight_class_color_maps_each_variant() { let t = Theme::dark(); - assert_eq!(t.highlight_class_color(HighlightClass::Keyword), t.tok_keyword); - assert_eq!(t.highlight_class_color(HighlightClass::Identifier), t.tok_identifier); + assert_eq!( + t.highlight_class_color(HighlightClass::Keyword), + t.tok_keyword + ); + assert_eq!( + t.highlight_class_color(HighlightClass::Identifier), + t.tok_identifier + ); assert_eq!(t.highlight_class_color(HighlightClass::Type), t.tok_type); - assert_eq!(t.highlight_class_color(HighlightClass::Number), t.tok_number); - assert_eq!(t.highlight_class_color(HighlightClass::String), t.tok_string); + assert_eq!( + t.highlight_class_color(HighlightClass::Number), + t.tok_number + ); + assert_eq!( + t.highlight_class_color(HighlightClass::String), + t.tok_string + ); assert_eq!(t.highlight_class_color(HighlightClass::Punct), t.tok_punct); assert_eq!(t.highlight_class_color(HighlightClass::Flag), t.tok_flag); - assert_eq!(t.highlight_class_color(HighlightClass::Function), t.tok_function); + assert_eq!( + t.highlight_class_color(HighlightClass::Function), + t.tok_function + ); assert_eq!(t.highlight_class_color(HighlightClass::Error), t.tok_error); } diff --git a/src/type_change.rs b/src/type_change.rs index e4360ac..fa3a2d9 100644 --- a/src/type_change.rs +++ b/src/type_change.rs @@ -87,9 +87,7 @@ pub fn static_refusal(src: Type, target: Type) -> Option { } const fn is_in_matrix(src: Type, target: Type) -> bool { - use Type::{ - Bool, Date, DateTime, Decimal, Int, Real, Serial, ShortId, Text, - }; + use Type::{Bool, Date, DateTime, Decimal, Int, Real, Serial, ShortId, Text}; matches!( (src, target), // Always-clean transformers @@ -130,9 +128,7 @@ pub fn transform_cell(src: Type, target: Type, value: &Value) -> CellOutcome { if matches!(value, Value::Null) { return CellOutcome::Clean(Value::Null); } - use Type::{ - Bool, Date, DateTime, Decimal, Int, Real, Serial, ShortId, Text, - }; + use Type::{Bool, Date, DateTime, Decimal, Int, Real, Serial, ShortId, Text}; match (src, target) { // ---- Always-clean: int / serial source ---- (Int | Serial, Real) => match value { @@ -179,9 +175,11 @@ pub fn transform_cell(src: Type, target: Type, value: &Value) -> CellOutcome { (Bool, Text) => match value { // "true" / "false" matches the DSL boolean grammar // (ADR-0014 §5), not raw 0/1 stringification. - Value::Integer(i) => CellOutcome::Clean(Value::Text( - if *i == 0 { "false".into() } else { "true".into() }, - )), + Value::Integer(i) => CellOutcome::Clean(Value::Text(if *i == 0 { + "false".into() + } else { + "true".into() + })), other => unexpected_storage("bool", other), }, @@ -369,9 +367,7 @@ pub fn transform_cell(src: Type, target: Type, value: &Value) -> CellOutcome { } } else { CellOutcome::Incompatible { - reason: format!( - "`{s}` is not a datetime in `YYYY-MM-DDTHH:MM:SS` form" - ), + reason: format!("`{s}` is not a datetime in `YYYY-MM-DDTHH:MM:SS` form"), } } } @@ -450,10 +446,7 @@ fn real_to_int(r: f64) -> CellOutcome { let discarded = r - r.trunc(); CellOutcome::Lossy { new: Value::Integer(truncated), - reason: format!( - "truncated; would discard {}", - format_real(discarded) - ), + reason: format!("truncated; would discard {}", format_real(discarded)), } } } @@ -555,9 +548,7 @@ fn format_real(r: f64) -> String { fn unexpected_storage(label: &str, value: &Value) -> CellOutcome { CellOutcome::Incompatible { - reason: format!( - "internal: cell stored unexpectedly for `{label}` source ({value:?})" - ), + reason: format!("internal: cell stored unexpectedly for `{label}` source ({value:?})"), } } @@ -638,7 +629,10 @@ mod tests { (Type::Date, Type::Int), (Type::ShortId, Type::Int), ] { - assert!(static_refusal(src, target).is_some(), "{src:?} -> {target:?}"); + assert!( + static_refusal(src, target).is_some(), + "{src:?} -> {target:?}" + ); } } @@ -672,7 +666,10 @@ mod tests { (Type::Bool, Type::Real), ]; for (s, t) in pairs { - assert_eq!(transform_cell(s, t, &Value::Null), CellOutcome::Clean(Value::Null)); + assert_eq!( + transform_cell(s, t, &Value::Null), + CellOutcome::Clean(Value::Null) + ); } } diff --git a/src/ui.rs b/src/ui.rs index d2f6f25..cdefc3f 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -196,7 +196,16 @@ fn render_badge_box(label: &str, area: Rect, above: Option, frame: &mut Fr area.y + area.height - box_h - 1 } }; - fill_overlay_rect(Rect { x, y, width: box_w, height: box_h }, label.to_string(), frame); + fill_overlay_rect( + Rect { + x, + y, + width: box_w, + height: box_h, + }, + label.to_string(), + frame, + ); } /// A step-caption box inset one cell from the bottom-right of `area` @@ -309,7 +318,9 @@ fn render_path_entry( let inner_w = dialog_w.saturating_sub(4) as usize; let prompt_lines = wrap_lines(&m.prompt, inner_w); // Title + blank + prompt + blank + input box (1 row + borders) + blank + key hints. - let dialog_h = (prompt_lines.len() as u16).saturating_add(8).min(area.height); + let dialog_h = (prompt_lines.len() as u16) + .saturating_add(8) + .min(area.height); let x = area.x + (area.width.saturating_sub(dialog_w)) / 2; let y = area.y + (area.height.saturating_sub(dialog_h)) / 2; let dialog_area = Rect { @@ -320,9 +331,7 @@ fn render_path_entry( }; frame.render_widget(ratatui::widgets::Clear, dialog_area); - let title_style = Style::default() - .fg(theme.fg) - .add_modifier(Modifier::BOLD); + let title_style = Style::default().fg(theme.fg).add_modifier(Modifier::BOLD); let block = Block::default() .borders(Borders::ALL) .border_type(BorderType::Rounded) @@ -386,9 +395,7 @@ fn render_load_picker( }; frame.render_widget(ratatui::widgets::Clear, dialog_area); - let title_style = Style::default() - .fg(theme.fg) - .add_modifier(Modifier::BOLD); + let title_style = Style::default().fg(theme.fg).add_modifier(Modifier::BOLD); let block = Block::default() .borders(Borders::ALL) .border_type(BorderType::Rounded) @@ -411,9 +418,7 @@ fn render_load_picker( let marker = if i == m.selected { "›" } else { " " }; let temp_tag = if entry.is_temp { "[TEMP] " } else { "" }; let style = if i == m.selected { - Style::default() - .fg(theme.fg) - .add_modifier(Modifier::BOLD) + Style::default().fg(theme.fg).add_modifier(Modifier::BOLD) } else { Style::default().fg(theme.fg) }; @@ -447,11 +452,7 @@ fn render_load_picker( let display_input = if *cursor == input.len() { format!("{input}{cursor_marker}") } else { - format!( - "{}{cursor_marker}{}", - &input[..*cursor], - &input[*cursor..] - ) + format!("{}{cursor_marker}{}", &input[..*cursor], &input[*cursor..]) }; text_lines.push(Line::from(format!("> {display_input}"))); text_lines.push(Line::from("")); @@ -500,9 +501,7 @@ fn render_rebuild_confirm(summary: &str, theme: &Theme, frame: &mut Frame<'_>, a let bg = ratatui::widgets::Clear; frame.render_widget(bg, dialog_area); - let title_style = Style::default() - .fg(theme.fg) - .add_modifier(Modifier::BOLD); + let title_style = Style::default().fg(theme.fg).add_modifier(Modifier::BOLD); let block = Block::default() .borders(Borders::ALL) .border_type(BorderType::Rounded) @@ -524,16 +523,12 @@ fn render_rebuild_confirm(summary: &str, theme: &Theme, frame: &mut Frame<'_>, a text_lines.push(Line::from(vec![ Span::styled( "[Y]", - Style::default() - .fg(theme.fg) - .add_modifier(Modifier::BOLD), + Style::default().fg(theme.fg).add_modifier(Modifier::BOLD), ), Span::raw(format!(" {} ", crate::t!("shortcut.yes"))), Span::styled( "[N]", - Style::default() - .fg(theme.fg) - .add_modifier(Modifier::BOLD), + Style::default().fg(theme.fg).add_modifier(Modifier::BOLD), ), Span::raw(format!(" {} ", crate::t!("shortcut.no"))), Span::styled("Esc", Style::default().fg(theme.muted)), @@ -578,18 +573,14 @@ where /// dialog (issue #13): wide enough to hold the longest content /// line on a single row, clamped to sane bounds and the available /// area so a short insert no longer wraps on roomy terminals. -fn undo_dialog_width( - content_widths: impl IntoIterator, - area_width: u16, -) -> u16 { +fn undo_dialog_width(content_widths: impl IntoIterator, area_width: u16) -> u16 { /// Floor — comfortably fits the button row plus borders. const MIN: u16 = 34; /// Ceiling for outlier (ultra-wide) terminals. const MAX: u16 = 100; let widest = content_widths.into_iter().max().unwrap_or(0); // +4: left/right border (2) + one padding column each side (2). - let preferred = - u16::try_from(widest).unwrap_or(u16::MAX).saturating_add(4); + let preferred = u16::try_from(widest).unwrap_or(u16::MAX).saturating_add(4); let upper = area_width.min(MAX); let lower = MIN.min(upper); preferred.clamp(lower, upper) @@ -617,8 +608,7 @@ fn render_undo_confirm( let intro_line = format!("{intro} {}", m.command); // Local-time, human-formatted snapshot stamp (issue #13). let when_display = format_snapshot_timestamp(&m.timestamp); - let when_line = - crate::t!("modal.undo_confirm_when", timestamp = when_display); + let when_line = crate::t!("modal.undo_confirm_when", timestamp = when_display); let prompt = crate::t!("modal.undo_confirm_prompt"); // Reconstruct the button row purely to measure its width — the // styled spans are built below. Keep this in sync with them. @@ -681,9 +671,15 @@ fn render_undo_confirm( text_lines.push(Line::from(prompt)); text_lines.push(Line::from("")); text_lines.push(Line::from(vec![ - Span::styled("[Y]", Style::default().fg(theme.fg).add_modifier(Modifier::BOLD)), + Span::styled( + "[Y]", + Style::default().fg(theme.fg).add_modifier(Modifier::BOLD), + ), Span::raw(format!(" {} ", crate::t!("shortcut.yes"))), - Span::styled("[N]", Style::default().fg(theme.fg).add_modifier(Modifier::BOLD)), + Span::styled( + "[N]", + Style::default().fg(theme.fg).add_modifier(Modifier::BOLD), + ), Span::raw(format!(" {} ", crate::t!("shortcut.no"))), Span::styled("Esc", Style::default().fg(theme.muted)), Span::styled( @@ -811,17 +807,13 @@ fn clamp_wrapped(text: &str, width: usize, max_rows: usize) -> Vec { fn render_project_label(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) { let label_style = Style::default().fg(theme.muted); - let value_style = Style::default() - .fg(theme.fg) - .add_modifier(Modifier::BOLD); + let value_style = Style::default().fg(theme.fg).add_modifier(Modifier::BOLD); let bar_style = Style::default().bg(theme.bg).fg(theme.muted); let no_project = crate::t!("status.no_project"); let display = app.project_name.as_deref().unwrap_or(no_project.as_str()); - let mut spans: Vec> = vec![Span::styled( - crate::t!("status.project_label"), - label_style, - )]; + let mut spans: Vec> = + vec![Span::styled(crate::t!("status.project_label"), label_style)]; if app.project_is_temp { spans.push(Span::styled( "[TEMP] ", @@ -875,9 +867,7 @@ fn render_items_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area: )) .title(Span::styled( format!(" {} ", crate::t!("panel.tables_title")), - Style::default() - .fg(theme.fg) - .add_modifier(Modifier::BOLD), + Style::default().fg(theme.fg).add_modifier(Modifier::BOLD), )) .style(Style::default().bg(theme.bg).fg(theme.fg)); @@ -918,9 +908,7 @@ fn render_items_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area: let mut lines: Vec> = Vec::new(); for name in &app.tables { let style = if name == highlight { - Style::default() - .fg(theme.fg) - .add_modifier(Modifier::BOLD) + Style::default().fg(theme.fg).add_modifier(Modifier::BOLD) } else { Style::default().fg(theme.fg) }; @@ -937,7 +925,9 @@ fn render_items_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area: } } } - let paragraph = Paragraph::new(lines).block(block).scroll((offset as u16, 0)); + let paragraph = Paragraph::new(lines) + .block(block) + .scroll((offset as u16, 0)); frame.render_widget(paragraph, area); } @@ -956,9 +946,7 @@ fn render_relationships_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_ )) .title(Span::styled( format!(" {} ", crate::t!("panel.relationships_title")), - Style::default() - .fg(theme.fg) - .add_modifier(Modifier::BOLD), + Style::default().fg(theme.fg).add_modifier(Modifier::BOLD), )) .style(Style::default().bg(theme.bg).fg(theme.fg)); @@ -992,12 +980,24 @@ fn render_relationships_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_ ellipsize(&rel.name, inner_w), name_style, ))); - let parent = format!(" {}.{} ->", rel.parent_table, rel.parent_columns.join(", ")); - lines.push(Line::from(Span::styled(ellipsize(&parent, inner_w), detail_style))); + let parent = format!( + " {}.{} ->", + rel.parent_table, + rel.parent_columns.join(", ") + ); + lines.push(Line::from(Span::styled( + ellipsize(&parent, inner_w), + detail_style, + ))); let child = format!(" {}.{}", rel.child_table, rel.child_columns.join(", ")); - lines.push(Line::from(Span::styled(ellipsize(&child, inner_w), detail_style))); + lines.push(Line::from(Span::styled( + ellipsize(&child, inner_w), + detail_style, + ))); } - let paragraph = Paragraph::new(lines).block(block).scroll((offset as u16, 0)); + let paragraph = Paragraph::new(lines) + .block(block) + .scroll((offset as u16, 0)); frame.render_widget(paragraph, area); } @@ -1022,9 +1022,7 @@ fn render_output_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area .border_style(Style::default().fg(theme.border)) .title(Span::styled( format!(" {} ", crate::t!("panel.output_title")), - Style::default() - .fg(theme.fg) - .add_modifier(Modifier::BOLD), + Style::default().fg(theme.fg).add_modifier(Modifier::BOLD), )) .style(Style::default().bg(theme.bg).fg(theme.fg)); @@ -1132,9 +1130,9 @@ const fn output_span_style(class: OutputStyleClass, theme: &Theme) -> Style { OutputStyleClass::Neutral => Style::new().fg(theme.fg), OutputStyleClass::Efficient => Style::new().fg(theme.plan_efficient), OutputStyleClass::Expensive => Style::new().fg(theme.warning), - OutputStyleClass::AutomaticIndex => Style::new() - .fg(theme.warning) - .add_modifier(Modifier::BOLD), + OutputStyleClass::AutomaticIndex => { + Style::new().fg(theme.warning).add_modifier(Modifier::BOLD) + } // ADR-0038 §4 / §6: de-emphasised text — the `Executing SQL:` // prefix and every category-3 prose line (caveat + the // existing `client_side.*` notes). `theme.muted` is the @@ -1239,9 +1237,7 @@ fn render_output_line<'a>(line: &'a OutputLine, theme: &Theme) -> Line<'a> { &line.text[..prefix_len], Style::default().fg(theme.muted), )); - for run in - crate::input_render::lex_to_runs_in_mode(rest, theme, Mode::Advanced) - { + for run in crate::input_render::lex_to_runs_in_mode(rest, theme, Mode::Advanced) { spans.push(Span::styled( &rest[run.byte_range.0..run.byte_range.1], run.style, @@ -1350,9 +1346,7 @@ fn render_input_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area: Span::raw(" "), Span::styled( label, - Style::default() - .fg(mode_color) - .add_modifier(Modifier::BOLD), + Style::default().fg(mode_color).add_modifier(Modifier::BOLD), ), Span::raw(" "), ]); @@ -1474,7 +1468,10 @@ fn render_input_one_row( if offset > 0 { frame.render_widget( Paragraph::new(Span::styled("<", marker)), - Rect { width: 1, ..text_area }, + Rect { + width: 1, + ..text_area + }, ); } if offset + eff < line_cols { @@ -1536,8 +1533,16 @@ fn render_input_two_rows( // Overflowing both rows reserves a marker column on each row's // outer edge; otherwise both rows use their full text width. let overflow = line_cols >= capacity; - let row0_text_w = if overflow { row0_w.saturating_sub(1) } else { row0_w }; - let row1_text_w = if overflow { row1_w.saturating_sub(1) } else { row1_w }; + let row0_text_w = if overflow { + row0_w.saturating_sub(1) + } else { + row0_w + }; + let row1_text_w = if overflow { + row1_w.saturating_sub(1) + } else { + row1_w + }; let eff_cap = row0_text_w + row1_text_w; let start = offset.min(len); @@ -1552,7 +1557,11 @@ fn render_input_two_rows( ) }; - let row0_x = if overflow { text_area.x + 1 } else { text_area.x }; + let row0_x = if overflow { + text_area.x + 1 + } else { + text_area.x + }; frame.render_widget( Paragraph::new(to_line(&window[..split])), Rect { @@ -1622,10 +1631,7 @@ fn expand_runs_to_cells( /// Convert `StyledRun`s into ratatui `Span`s borrowed from /// `input`. The end-of-input cursor sentinel (empty range) is /// rendered as an inverted space. -fn runs_to_spans<'a>( - input: &'a str, - runs: &[crate::input_render::StyledRun], -) -> Vec> { +fn runs_to_spans<'a>(input: &'a str, runs: &[crate::input_render::StyledRun]) -> Vec> { runs.iter() .map(|r| { if r.byte_range.0 == r.byte_range.1 { @@ -1710,21 +1716,14 @@ fn resolve_hint_lines( } } -fn render_hint_panel( - theme: &Theme, - frame: &mut Frame<'_>, - area: Rect, - lines: Vec>, -) { +fn render_hint_panel(theme: &Theme, frame: &mut Frame<'_>, area: Rect, lines: Vec>) { let block = Block::default() .borders(Borders::ALL) .border_type(BorderType::Rounded) .border_style(Style::default().fg(theme.border)) .title(Span::styled( format!(" {} ", crate::t!("panel.hint_title")), - Style::default() - .fg(theme.fg) - .add_modifier(Modifier::BOLD), + Style::default().fg(theme.fg).add_modifier(Modifier::BOLD), )) .style(Style::default().bg(theme.bg).fg(theme.fg)); @@ -1918,9 +1917,7 @@ fn status_bar_bindings(app: &App) -> Vec<(&'static str, String)> { } fn render_status_bar(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) { - let key_style = Style::default() - .fg(theme.fg) - .add_modifier(Modifier::BOLD); + let key_style = Style::default().fg(theme.fg).add_modifier(Modifier::BOLD); let sep_style = Style::default().fg(theme.muted); let label_style = Style::default().fg(theme.muted); let bar_style = Style::default().bg(theme.bg).fg(theme.muted); @@ -2016,9 +2013,16 @@ mod tests { }; let rendered = render_output_line(&line, &theme); // [system] tag, then the dim prefix, then ≥1 SQL spans. - assert!(rendered.spans.len() >= 3, "tag + prefix + sql: {:?}", rendered.spans); + assert!( + rendered.spans.len() >= 3, + "tag + prefix + sql: {:?}", + rendered.spans + ); assert_eq!(rendered.spans[0].content.as_ref(), "[system] "); - assert_eq!(rendered.spans[1].content.as_ref(), crate::echo::TEACHING_ECHO_LABEL); + assert_eq!( + rendered.spans[1].content.as_ref(), + crate::echo::TEACHING_ECHO_LABEL + ); assert_eq!( rendered.spans[1].style.fg, Some(theme.muted), @@ -2152,17 +2156,41 @@ mod tests { use crate::completion::{Candidate, CandidateKind, ModeClass}; let theme = Theme::dark(); let items = vec![ - Candidate { text: "table".into(), kind: CandidateKind::Keyword, mode: ModeClass::Both }, - Candidate { text: "index".into(), kind: CandidateKind::Keyword, mode: ModeClass::Advanced }, - Candidate { text: "relationship".into(), kind: CandidateKind::Keyword, mode: ModeClass::Simple }, + Candidate { + text: "table".into(), + kind: CandidateKind::Keyword, + mode: ModeClass::Both, + }, + Candidate { + text: "index".into(), + kind: CandidateKind::Keyword, + mode: ModeClass::Advanced, + }, + Candidate { + text: "relationship".into(), + kind: CandidateKind::Keyword, + mode: ModeClass::Simple, + }, ]; let line = render_candidate_line(&items, None, 100, &theme); assert_eq!(line.spans[0].content.as_ref(), "table"); - assert_eq!(line.spans[0].style.fg, Some(theme.tok_keyword), "Both keeps the kind colour"); + assert_eq!( + line.spans[0].style.fg, + Some(theme.tok_keyword), + "Both keeps the kind colour" + ); assert_eq!(line.spans[2].content.as_ref(), "index"); - assert_eq!(line.spans[2].style.fg, Some(theme.mode_advanced), "Advanced → advanced colour"); + assert_eq!( + line.spans[2].style.fg, + Some(theme.mode_advanced), + "Advanced → advanced colour" + ); assert_eq!(line.spans[4].content.as_ref(), "relationship"); - assert_eq!(line.spans[4].style.fg, Some(theme.mode_simple), "Simple → simple colour"); + assert_eq!( + line.spans[4].style.fg, + Some(theme.mode_simple), + "Simple → simple colour" + ); } #[test] @@ -2173,8 +2201,16 @@ mod tests { use crate::completion::{Candidate, CandidateKind, ModeClass}; let theme = Theme::dark(); let items = vec![ - Candidate { text: "values".into(), kind: CandidateKind::Keyword, mode: ModeClass::Advanced }, - Candidate { text: "select".into(), kind: CandidateKind::Keyword, mode: ModeClass::Advanced }, + Candidate { + text: "values".into(), + kind: CandidateKind::Keyword, + mode: ModeClass::Advanced, + }, + Candidate { + text: "select".into(), + kind: CandidateKind::Keyword, + mode: ModeClass::Advanced, + }, ]; let line = render_candidate_line(&items, None, 100, &theme); assert_eq!( @@ -2248,7 +2284,10 @@ mod tests { "the error body is neutral fg, not flooded red", ); assert!( - rendered.spans[1].style.add_modifier.contains(Modifier::BOLD), + rendered.spans[1] + .style + .add_modifier + .contains(Modifier::BOLD), "the error body is bold for weight without the red-wall readability cost", ); } @@ -2509,10 +2548,14 @@ mod tests { "the tail around the cursor must be visible:\n{out}" ); assert!( - !out.lines().any(|l| l.contains("select * from Customers where")), + !out.lines() + .any(|l| l.contains("select * from Customers where")), "the head must be scrolled off:\n{out}" ); - assert!(out.contains('<'), "a left scroll marker signals the hidden head:\n{out}"); + assert!( + out.contains('<'), + "a left scroll marker signals the hidden head:\n{out}" + ); } #[test] @@ -2525,9 +2568,18 @@ mod tests { let theme = Theme::dark(); // Narrow (sidebar hidden, DB1) so the line overflows the field. let out = render_to_string(&mut app, &theme, 60, 24); - assert!(out.contains("select * from"), "head visible at Home:\n{out}"); - assert!(out.contains('>'), "a right scroll marker signals the hidden tail:\n{out}"); - assert!(!out.contains("Wonderland"), "the tail must be scrolled off:\n{out}"); + assert!( + out.contains("select * from"), + "head visible at Home:\n{out}" + ); + assert!( + out.contains('>'), + "a right scroll marker signals the hidden tail:\n{out}" + ); + assert!( + !out.contains("Wonderland"), + "the tail must be scrolled off:\n{out}" + ); } // ---- ADR-0046 DA4: two-row input on tall terminals ----------- @@ -2569,8 +2621,14 @@ mod tests { let theme = Theme::dark(); // Very narrow + tall: two rows, but the line exceeds both. let out = render_to_string(&mut app, &theme, 38, 44); - assert!(out.contains("Wonderland"), "the tail/cursor stays visible:\n{out}"); - assert!(out.contains('<'), "a left marker signals the hidden head:\n{out}"); + assert!( + out.contains("Wonderland"), + "the tail/cursor stays visible:\n{out}" + ); + assert!( + out.contains('<'), + "a left marker signals the hidden head:\n{out}" + ); } #[test] @@ -2644,7 +2702,10 @@ mod tests { /// 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() + status_bar_bindings(app) + .into_iter() + .map(|(k, _)| k) + .collect() } /// The full rendered strip text (keys + labels + separators). @@ -2659,7 +2720,12 @@ mod tests { fn hint_text(lines: &[Line<'_>]) -> String { lines .iter() - .map(|l| l.spans.iter().map(|s| s.content.clone()).collect::()) + .map(|l| { + l.spans + .iter() + .map(|s| s.content.clone()) + .collect::() + }) .collect::>() .join("\n") } @@ -2686,10 +2752,7 @@ mod tests { 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"], - ); + 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"]); @@ -2802,11 +2865,7 @@ mod tests { assert_eq!(two, vec!["alpha beta", "gamma delta"]); // > max rows: clamp to max, last row ends with an ellipsis, // and every row stays within the width. - let many = clamp_wrapped( - "alpha beta gamma delta epsilon zeta eta theta iota", - 11, - 3, - ); + let many = clamp_wrapped("alpha beta gamma delta epsilon zeta eta theta iota", 11, 3); assert_eq!(many.len(), 3); assert!(many[2].ends_with('…'), "last row ellipsized: {many:?}"); for row in &many { @@ -2931,9 +2990,18 @@ mod tests { app.output.push_back(err); let out = render_to_string(&mut app, &Theme::dark(), 100, 20); - assert!(out.contains("running: drop table Orders"), "pending keeps running::\n{out}"); - assert!(out.contains("create table T with pk ✓"), "ok shows ✓:\n{out}"); - assert!(out.contains("insert into T values (1) ✗"), "err shows ✗:\n{out}"); + assert!( + out.contains("running: drop table Orders"), + "pending keeps running::\n{out}" + ); + assert!( + out.contains("create table T with pk ✓"), + "ok shows ✓:\n{out}" + ); + assert!( + out.contains("insert into T values (1) ✗"), + "err shows ✗:\n{out}" + ); assert!( !out.contains("running: create table"), "a completed echo drops the running: prefix:\n{out}" @@ -2970,7 +3038,10 @@ mod tests { #[test] fn format_snapshot_timestamp_falls_back_on_garbage() { - assert_eq!(format_snapshot_timestamp("not a timestamp"), "not a timestamp"); + assert_eq!( + format_snapshot_timestamp("not a timestamp"), + "not a timestamp" + ); } #[test] @@ -2999,9 +3070,9 @@ mod tests { let theme = Theme::dark(); let out = render_to_string(&mut app, &theme, 120, 30); assert!( - out.lines().any(|l| l.contains( - "This will undo: insert into Customers values (1, 'Oliver Sturm')" - )), + out.lines() + .any(|l| l + .contains("This will undo: insert into Customers values (1, 'Oliver Sturm')")), "command must sit on one row on a wide terminal:\n{out}" ); } @@ -3017,7 +3088,10 @@ mod tests { })); let theme = Theme::dark(); let out = render_to_string(&mut app, &theme, 120, 30); - assert!(out.contains("Snapshot taken"), "capitalized Snapshot:\n{out}"); + assert!( + out.contains("Snapshot taken"), + "capitalized Snapshot:\n{out}" + ); assert!(out.contains("[Y] Yes"), "capitalized Yes:\n{out}"); assert!(out.contains("[N] No"), "capitalized No:\n{out}"); assert!( @@ -3113,8 +3187,14 @@ mod tests { app.schema_cache.table_indexes.insert( "Customers".to_string(), vec![ - IndexEntry { name: "idx_email".to_string(), unique: false }, - IndexEntry { name: "uidx_login".to_string(), unique: true }, + IndexEntry { + name: "idx_email".to_string(), + unique: false, + }, + IndexEntry { + name: "uidx_login".to_string(), + unique: true, + }, ], ); let theme = Theme::dark(); @@ -3123,7 +3203,10 @@ mod tests { assert!(out.contains("Customers"), "table listed:\n{out}"); assert!(out.contains("Orders"), "table listed:\n{out}"); assert!(out.contains("idx_email"), "index nested in panel:\n{out}"); - assert!(out.contains("uidx_login [unique]"), "unique index marked:\n{out}"); + assert!( + out.contains("uidx_login [unique]"), + "unique index marked:\n{out}" + ); } #[test] @@ -3143,10 +3226,19 @@ mod tests { app.tables = vec!["Customers".to_string()]; let theme = Theme::dark(); let narrow = render_to_string(&mut app, &theme, 80, 24); - assert!(!narrow.contains("Tables"), "sidebar hidden at 80 wide:\n{narrow}"); + assert!( + !narrow.contains("Tables"), + "sidebar hidden at 80 wide:\n{narrow}" + ); let wide = render_to_string(&mut app, &theme, 110, 24); - assert!(wide.contains("Tables"), "sidebar shown at 110 wide:\n{wide}"); - assert!(wide.contains("Customers"), "tables listed when shown:\n{wide}"); + assert!( + wide.contains("Tables"), + "sidebar shown at 110 wide:\n{wide}" + ); + assert!( + wide.contains("Customers"), + "tables listed when shown:\n{wide}" + ); } #[test] @@ -3181,7 +3273,10 @@ mod tests { let theme = Theme::dark(); let out = render_to_string(&mut app, &theme, 110, 24); assert!(out.contains("Relationships"), "panel title present:\n{out}"); - assert!(out.contains("Customers_Orders"), "relationship name:\n{out}"); + assert!( + out.contains("Customers_Orders"), + "relationship name:\n{out}" + ); assert!( out.lines().any(|l| l.contains("Customers.id ->")), "parent endpoint, broken at the arrow:\n{out}" @@ -3228,8 +3323,14 @@ mod tests { app.nav_focus = NavFocus::SidebarTables; let focused = render_to_string(&mut app, &theme, 80, 24); - assert!(focused.contains("Tables"), "sidebar revealed in nav mode:\n{focused}"); - assert!(focused.contains("Customers"), "tables in the overlay:\n{focused}"); + assert!( + focused.contains("Tables"), + "sidebar revealed in nav mode:\n{focused}" + ); + assert!( + focused.contains("Customers"), + "tables in the overlay:\n{focused}" + ); assert!( focused.contains("Relationships"), "relationships panel in the overlay:\n{focused}" @@ -3365,7 +3466,9 @@ mod tests { } let backend = TestBackend::new(width, height); let mut terminal = Terminal::new(backend).expect("create terminal"); - terminal.draw(|f| render(app, theme, f)).expect("draw frame"); + terminal + .draw(|f| render(app, theme, f)) + .expect("draw frame"); terminal.backend().buffer().clone() } diff --git a/src/undo.rs b/src/undo.rs index a2df9fa..9dd2158 100644 --- a/src/undo.rs +++ b/src/undo.rs @@ -737,7 +737,11 @@ mod tests { let payload_dirs = fs::read_dir(&store.root) .unwrap() .filter_map(std::result::Result::ok) - .filter(|e| e.file_name().to_str().is_some_and(|n| n.parse::().is_ok())) + .filter(|e| { + e.file_name() + .to_str() + .is_some_and(|n| n.parse::().is_ok()) + }) .count(); assert_eq!(payload_dirs, 2, "ring capped at 2 payloads"); } @@ -766,7 +770,11 @@ mod tests { let payload_dirs = fs::read_dir(&store.root) .unwrap() .filter_map(std::result::Result::ok) - .filter(|e| e.file_name().to_str().is_some_and(|n| n.parse::().is_ok())) + .filter(|e| { + e.file_name() + .to_str() + .is_some_and(|n| n.parse::().is_ok()) + }) .count(); assert_eq!(payload_dirs, 1, "only the surviving undo payload remains"); } @@ -820,6 +828,10 @@ mod tests { fs::create_dir_all(store.payload_dir(41)).unwrap(); stage_finalize(&store, &fx.conn, "cmd"); let meta = store.peek_undo().unwrap().unwrap(); - assert!(meta.id >= 42, "id allocated above the orphan, got {}", meta.id); + assert!( + meta.id >= 42, + "id allocated above the orphan, got {}", + meta.id + ); } } diff --git a/tests/it/case_insensitive_names.rs b/tests/it/case_insensitive_names.rs index fe28129..b751a5f 100644 --- a/tests/it/case_insensitive_names.rs +++ b/tests/it/case_insensitive_names.rs @@ -24,8 +24,7 @@ fn rt() -> tokio::runtime::Runtime { fn open() -> (project::Project, Database, tempfile::TempDir) { let dir = tempfile::tempdir().expect("create tempdir"); - let project = - project::open_or_create(None, Some(dir.path())).expect("open or create project"); + let project = project::open_or_create(None, Some(dir.path())).expect("open or create project"); let db = Database::open_with_persistence( project.db_path(), Persistence::new(project.path().to_path_buf()), @@ -78,7 +77,10 @@ fn rename_column_with_case_variant_table_keeps_metadata_in_step() { let r = rt(); r.block_on(db.create_table( "Items".to_string(), - vec![ColumnSpec::new("id", Type::Int), ColumnSpec::new("qty", Type::Int)], + vec![ + ColumnSpec::new("id", Type::Int), + ColumnSpec::new("qty", Type::Int), + ], vec!["id".to_string()], Some("create".to_string()), )) @@ -129,7 +131,11 @@ fn insert_with_case_variant_table_persists_and_survives_rebuild() { .block_on(db.query_data("Items".to_string(), None, None)) .expect("query") .rows; - assert_eq!(rows.len(), 1, "the wrong-case insert survived the rebuild (no data loss)"); + assert_eq!( + rows.len(), + 1, + "the wrong-case insert survived the rebuild (no data loss)" + ); assert_eq!(rows[0][1].as_deref(), Some("kept")); } @@ -146,9 +152,19 @@ fn add_column_with_case_variant_table_survives_rebuild() { ); let db = fresh_rebuild(db, &project, &r); - let desc = r.block_on(db.describe_table("Items".to_string())).expect("describe"); - let qty = desc.columns.iter().find(|c| c.name == "qty").expect("qty added"); - assert_eq!(qty.user_type, Some(Type::Int), "qty's user-type survived the rebuild"); + let desc = r + .block_on(db.describe_table("Items".to_string())) + .expect("describe"); + let qty = desc + .columns + .iter() + .find(|c| c.name == "qty") + .expect("qty added"); + assert_eq!( + qty.user_type, + Some(Type::Int), + "qty's user-type survived the rebuild" + ); // The CHECK is intact too (a negative qty is refused under the real table). assert!( r.block_on(db.insert( @@ -175,9 +191,15 @@ fn drop_table_with_case_variant_name_clears_table_and_csv() { insert into Items (id, note) values (1, 'x')\n\ drop table items\n", ); - assert!(!tables(&db, &r).contains(&"Items".to_string()), "the table was dropped"); + assert!( + !tables(&db, &r).contains(&"Items".to_string()), + "the table was dropped" + ); let csv = project.path().join(project::DATA_DIR).join("Items.csv"); - assert!(!csv.exists(), "the CSV was removed despite the case-variant drop"); + assert!( + !csv.exists(), + "the CSV was removed despite the case-variant drop" + ); // A fresh rebuild yields no Items (the metadata/yaml has no orphan). let db = fresh_rebuild(db, &project, &r); @@ -224,12 +246,24 @@ fn add_relationship_with_case_variant_tables_survives_rebuild() { add 1:n relationship from parent.id to child.parent_id\n", ); // The parent's inbound relationship is visible under the stored case. - let p = r.block_on(db.describe_table("Parent".to_string())).expect("describe Parent"); - assert_eq!(p.inbound_relationships.len(), 1, "relationship recorded under the stored case"); + let p = r + .block_on(db.describe_table("Parent".to_string())) + .expect("describe Parent"); + assert_eq!( + p.inbound_relationships.len(), + 1, + "relationship recorded under the stored case" + ); assert_eq!(p.inbound_relationships[0].other_table, "Child"); let db = fresh_rebuild(db, &project, &r); - let p = r.block_on(db.describe_table("Parent".to_string())).expect("describe Parent"); - assert_eq!(p.inbound_relationships.len(), 1, "relationship survived the rebuild"); + let p = r + .block_on(db.describe_table("Parent".to_string())) + .expect("describe Parent"); + assert_eq!( + p.inbound_relationships.len(), + 1, + "relationship survived the rebuild" + ); assert_eq!(p.inbound_relationships[0].other_table, "Child"); } diff --git a/tests/it/column_op_guards.rs b/tests/it/column_op_guards.rs index bc9f43e..4c6cafb 100644 --- a/tests/it/column_op_guards.rs +++ b/tests/it/column_op_guards.rs @@ -24,8 +24,7 @@ fn rt() -> tokio::runtime::Runtime { fn open() -> (project::Project, Database, tempfile::TempDir) { let dir = tempfile::tempdir().expect("create tempdir"); - let project = - project::open_or_create(None, Some(dir.path())).expect("open or create project"); + let project = project::open_or_create(None, Some(dir.path())).expect("open or create project"); let persistence = Persistence::new(project.path().to_path_buf()); let db = Database::open_with_persistence_and_undo(project.db_path(), persistence, true) .expect("open db with persistence"); @@ -48,7 +47,9 @@ fn make_t_with_check(db: &Database, r: &tokio::runtime::Runtime) { vec!["a < b".to_string()], vec![], false, - Some("create table T (id int primary key, a int, b int, c text, check (a < b))".to_string()), + Some( + "create table T (id int primary key, a int, b int, c text, check (a < b))".to_string(), + ), )) .expect("create T with table CHECK"); } @@ -285,7 +286,10 @@ fn drop_column_covered_by_a_composite_unique_is_refused_with_the_derived_name() .block_on(db.drop_column("T".to_string(), "a".to_string(), false, None)) .expect_err("dropping a composite-UNIQUE column is refused"); let msg = err.friendly_message(); - assert!(msg.contains("unique_a_b"), "names the derived constraint; got: {msg}"); + assert!( + msg.contains("unique_a_b"), + "names the derived constraint; got: {msg}" + ); assert!( msg.contains("drop constraint unique_a_b"), "points at the actionable drop command; got: {msg}" @@ -351,14 +355,24 @@ fn rename_column_with_a_column_level_check_is_refused() { make_t_with_column_checks(&db, &r); // `qty`'s own check `qty >= 0` references qty → refused. assert!( - r.block_on(db.rename_column("T".to_string(), "qty".to_string(), "amount".to_string(), None)) - .is_err(), + r.block_on(db.rename_column( + "T".to_string(), + "qty".to_string(), + "amount".to_string(), + None + )) + .is_err(), "renaming a column with its own column-level CHECK is refused" ); // `price` is referenced by `discount`'s check `discount < price`. assert!( - r.block_on(db.rename_column("T".to_string(), "price".to_string(), "cost".to_string(), None)) - .is_err(), + r.block_on(db.rename_column( + "T".to_string(), + "price".to_string(), + "cost".to_string(), + None + )) + .is_err(), "renaming a column referenced by another column's CHECK is refused" ); // `id` is referenced by no CHECK → rename succeeds. diff --git a/tests/it/compound_fk.rs b/tests/it/compound_fk.rs index 7ab6000..b6f2f94 100644 --- a/tests/it/compound_fk.rs +++ b/tests/it/compound_fk.rs @@ -9,7 +9,7 @@ use rdbms_playground::db::Database; use rdbms_playground::dsl::{ - parse_command, ColumnSpec, Command, ReferentialAction, SqlForeignKey, Type, Value, + ColumnSpec, Command, ReferentialAction, SqlForeignKey, Type, Value, parse_command, }; use rdbms_playground::persistence::Persistence; use rdbms_playground::project; @@ -18,10 +18,8 @@ use rdbms_playground::project; #[test] fn parenthesized_compound_endpoint_parses_to_column_lists() { - let cmd = parse_command( - "add 1:n relationship from Parent.(a, b) to Child.(x, y)", - ) - .expect("parses"); + let cmd = + parse_command("add 1:n relationship from Parent.(a, b) to Child.(x, y)").expect("parses"); match cmd { Command::AddRelationship { parent_table, @@ -41,8 +39,7 @@ fn parenthesized_compound_endpoint_parses_to_column_lists() { #[test] fn single_column_endpoint_still_parses_unparenthesized() { - let cmd = parse_command("add 1:n relationship from Parent.id to Child.pid") - .expect("parses"); + let cmd = parse_command("add 1:n relationship from Parent.id to Child.pid").expect("parses"); match cmd { Command::AddRelationship { parent_columns, @@ -148,7 +145,10 @@ fn sql_create_table_compound_fk_executes_and_enforces() { db.insert( "Region".to_string(), Some(vec!["country".to_string(), "code".to_string()]), - vec![Value::Number("1".to_string()), Value::Number("10".to_string())], + vec![ + Value::Number("1".to_string()), + Value::Number("10".to_string()), + ], None, ) .await @@ -157,7 +157,10 @@ fn sql_create_table_compound_fk_executes_and_enforces() { .insert( "City".to_string(), Some(vec!["country".to_string(), "region_code".to_string()]), - vec![Value::Number("9".to_string()), Value::Number("9".to_string())], + vec![ + Value::Number("9".to_string()), + Value::Number("9".to_string()), + ], None, ) .await; @@ -176,8 +179,7 @@ fn rt() -> tokio::runtime::Runtime { fn open_project_db() -> (project::Project, Database, tempfile::TempDir) { let dir = tempfile::tempdir().expect("create tempdir"); - let project = - project::open_or_create(None, Some(dir.path())).expect("open or create project"); + let project = project::open_or_create(None, Some(dir.path())).expect("open or create project"); let persistence = Persistence::new(project.path().to_path_buf()); let db = Database::open_with_persistence(project.db_path(), persistence) .expect("open db with persistence"); @@ -241,7 +243,10 @@ fn compound_fk_declares_enforces_and_round_trips() { db.insert( "Region".to_string(), Some(vec!["country".to_string(), "code".to_string()]), - vec![Value::Number("1".to_string()), Value::Number("10".to_string())], + vec![ + Value::Number("1".to_string()), + Value::Number("10".to_string()), + ], None, ) .await @@ -253,7 +258,11 @@ fn compound_fk_declares_enforces_and_round_trips() { "region_code".to_string(), "name".to_string(), ]), - vec![Value::Number("1".to_string()), Value::Number("10".to_string()), Value::Text("Metropolis".to_string())], + vec![ + Value::Number("1".to_string()), + Value::Number("10".to_string()), + Value::Text("Metropolis".to_string()), + ], None, ) .await @@ -266,7 +275,11 @@ fn compound_fk_declares_enforces_and_round_trips() { "region_code".to_string(), "name".to_string(), ]), - vec![Value::Number("9".to_string()), Value::Number("9".to_string()), Value::Text("Nowhere".to_string())], + vec![ + Value::Number("9".to_string()), + Value::Number("9".to_string()), + Value::Text("Nowhere".to_string()), + ], None, ) .await; @@ -360,7 +373,10 @@ fn compound_fk_arity_mismatch_is_refused() { None, ) .await; - assert!(err.is_err(), "mismatched child/parent arity must be refused"); + assert!( + err.is_err(), + "mismatched child/parent arity must be refused" + ); }); } @@ -386,10 +402,8 @@ fn inline_fk_referencing_compound_pk_points_at_table_level_form() { .expect("create Region"); // Parse the inline form so the `inline` flag is set by the grammar. - let cmd = parse_command( - "create table City (country int references Region(country, code))", - ) - .expect("parses"); + let cmd = parse_command("create table City (country int references Region(country, code))") + .expect("parses"); let Command::SqlCreateTable { name, columns, @@ -465,7 +479,10 @@ fn compound_fk_type_mismatch_per_pair_is_refused() { None, ) .await; - assert!(err.is_err(), "a type-incompatible column pair must be refused"); + assert!( + err.is_err(), + "a type-incompatible column pair must be refused" + ); }); } @@ -478,11 +495,8 @@ fn compound_fk_survives_rebuild_from_text() { let path = project.path().to_path_buf(); let rt = rt(); { - let db = Database::open_with_persistence( - project.db_path(), - Persistence::new(path.clone()), - ) - .expect("open db"); + let db = Database::open_with_persistence(project.db_path(), Persistence::new(path.clone())) + .expect("open db"); rt.block_on(async { seed_compound(&db).await; db.add_relationship( @@ -512,7 +526,10 @@ fn compound_fk_survives_rebuild_from_text() { db.insert( "Region".to_string(), Some(vec!["country".to_string(), "code".to_string()]), - vec![Value::Number("1".to_string()), Value::Number("10".to_string())], + vec![ + Value::Number("1".to_string()), + Value::Number("10".to_string()), + ], None, ) .await @@ -521,11 +538,17 @@ fn compound_fk_survives_rebuild_from_text() { .insert( "City".to_string(), Some(vec!["country".to_string(), "region_code".to_string()]), - vec![Value::Number("9".to_string()), Value::Number("9".to_string())], + vec![ + Value::Number("9".to_string()), + Value::Number("9".to_string()), + ], None, ) .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. let city = db.describe_table("City".to_string()).await.unwrap(); assert_eq!( diff --git a/tests/it/engine_vocabulary_audit.rs b/tests/it/engine_vocabulary_audit.rs index b579135..ebe6f94 100644 --- a/tests/it/engine_vocabulary_audit.rs +++ b/tests/it/engine_vocabulary_audit.rs @@ -32,10 +32,8 @@ use rdbms_playground::event::AppEvent; const FORBIDDEN: &[&str] = &[ // Product names. - "SQLite", "sqlite", - // Crate name. - "rusqlite", - // Engine-specific keywords / idioms. + "SQLite", "sqlite", // Crate name. + "rusqlite", // Engine-specific keywords / idioms. "STRICT", "PRAGMA", ]; @@ -52,9 +50,7 @@ fn engine_vocab_leak(s: &str) -> Option<(&'static str, usize)> { fn assert_clean(label: &str, s: &str) { if let Some((needle, pos)) = engine_vocab_leak(s) { - panic!( - "ADR-0002 leak in {label}: found `{needle}` at byte {pos} in:\n{s}" - ); + panic!("ADR-0002 leak in {label}: found `{needle}` at byte {pos} in:\n{s}"); } } @@ -118,8 +114,7 @@ fn parse_errors_use_no_engine_vocabulary() { "this is not a command", ]; for input in inputs { - let err = parse_command(input) - .expect_err(&format!("expected parse failure for `{input}`")); + let err = parse_command(input).expect_err(&format!("expected parse failure for `{input}`")); let rendered = format!("{err:?}"); assert_clean(&format!("parse error for `{input}`"), &rendered); } diff --git a/tests/it/friendly_enrichment.rs b/tests/it/friendly_enrichment.rs index 30c46b0..78ad04e 100644 --- a/tests/it/friendly_enrichment.rs +++ b/tests/it/friendly_enrichment.rs @@ -18,10 +18,10 @@ use tokio::runtime::Runtime; use rdbms_playground::db::{Database, DbError, SqliteErrorKind}; -use rdbms_playground::dsl::{ - action::ReferentialAction, ColumnSpec, Command, RowFilter, Type, Value, -}; use rdbms_playground::dsl::parser::parse_command; +use rdbms_playground::dsl::{ + ColumnSpec, Command, RowFilter, Type, Value, action::ReferentialAction, +}; use rdbms_playground::runtime::enrich_dsl_failure; fn rt() -> Runtime { @@ -57,7 +57,10 @@ fn enrich_unique_insert_resolves_table_column_value_and_pinpoint() { db.insert( "Customers".to_string(), None, - vec![Value::Number("5".to_string()), Value::Text("Alice".to_string())], + vec![ + Value::Number("5".to_string()), + Value::Text("Alice".to_string()), + ], None, ) .await @@ -86,7 +89,10 @@ fn enrich_unique_insert_resolves_table_column_value_and_pinpoint() { .unwrap_err(); assert!(matches!( err, - DbError::Sqlite { kind: SqliteErrorKind::UniqueViolation, .. } + DbError::Sqlite { + kind: SqliteErrorKind::UniqueViolation, + .. + } )); let facts = enrich_dsl_failure(&db, &cmd, &err).await; @@ -169,7 +175,10 @@ fn enrich_unique_sql_insert_natural_order_resolves_value_via_schema() { db.insert( "Customers".to_string(), None, - vec![Value::Number("5".to_string()), Value::Text("Alice".to_string())], + vec![ + Value::Number("5".to_string()), + Value::Text("Alice".to_string()), + ], None, ) .await @@ -189,7 +198,10 @@ fn enrich_unique_sql_insert_natural_order_resolves_value_via_schema() { else { panic!("expected Command::SqlInsert, got {cmd:?}"); }; - assert!(listed_columns.is_empty(), "natural-order form has no column list"); + assert!( + listed_columns.is_empty(), + "natural-order form has no column list" + ); let err = db .run_sql_insert_with_literals( sql, @@ -204,7 +216,10 @@ fn enrich_unique_sql_insert_natural_order_resolves_value_via_schema() { .unwrap_err(); assert!(matches!( err, - DbError::Sqlite { kind: SqliteErrorKind::UniqueViolation, .. } + DbError::Sqlite { + kind: SqliteErrorKind::UniqueViolation, + .. + } )); let facts = enrich_dsl_failure(&db, &cmd, &err).await; @@ -235,7 +250,10 @@ fn enrich_unique_update_resolves_value_from_assignments() { db.insert( "Customers".to_string(), None, - vec![Value::Number("1".to_string()), Value::Text("Alice".to_string())], + vec![ + Value::Number("1".to_string()), + Value::Text("Alice".to_string()), + ], None, ) .await @@ -243,7 +261,10 @@ fn enrich_unique_update_resolves_value_from_assignments() { db.insert( "Customers".to_string(), None, - vec![Value::Number("2".to_string()), Value::Text("Bob".to_string())], + vec![ + Value::Number("2".to_string()), + Value::Text("Bob".to_string()), + ], None, ) .await @@ -294,7 +315,10 @@ fn enrich_unique_sql_update_resolves_value_from_set_literals() { db.insert( "Customers".to_string(), None, - vec![Value::Number("1".to_string()), Value::Text("Alice".to_string())], + vec![ + Value::Number("1".to_string()), + Value::Text("Alice".to_string()), + ], None, ) .await @@ -302,7 +326,10 @@ fn enrich_unique_sql_update_resolves_value_from_set_literals() { db.insert( "Customers".to_string(), None, - vec![Value::Number("2".to_string()), Value::Text("Bob".to_string())], + vec![ + Value::Number("2".to_string()), + Value::Text("Bob".to_string()), + ], None, ) .await @@ -328,7 +355,10 @@ fn enrich_unique_sql_update_resolves_value_from_set_literals() { .unwrap_err(); assert!(matches!( err, - DbError::Sqlite { kind: SqliteErrorKind::UniqueViolation, .. } + DbError::Sqlite { + kind: SqliteErrorKind::UniqueViolation, + .. + } )); let facts = enrich_dsl_failure(&db, &cmd, &err).await; @@ -666,7 +696,10 @@ fn enrich_fk_delete_resolves_child_table() { db.insert( "Orders".to_string(), None, - vec![Value::Number("1".to_string()), Value::Number("1".to_string())], + vec![ + Value::Number("1".to_string()), + Value::Number("1".to_string()), + ], None, ) .await @@ -708,16 +741,15 @@ fn enrich_check_insert_resolves_table_column_value_and_rule() { ) .await .unwrap(); - let score_spec = match parse_command( - "create table __probe with pk score(int) check (score >= 0)", - ) - .expect("probe create parses") - { - Command::CreateTable { columns, .. } => { - columns.into_iter().next().expect("one column") - } - other => panic!("expected CreateTable, got {other:?}"), - }; + let score_spec = + match parse_command("create table __probe with pk score(int) check (score >= 0)") + .expect("probe create parses") + { + Command::CreateTable { columns, .. } => { + columns.into_iter().next().expect("one column") + } + other => panic!("expected CreateTable, got {other:?}"), + }; db.add_column("Scores".to_string(), score_spec, None) .await .unwrap(); @@ -757,7 +789,9 @@ fn enrich_unsupported_returns_default_facts() { let db = db(); rt().block_on(async { let err = DbError::Unsupported("nope".to_string()); - let cmd = Command::DropTable { name: "X".to_string() }; + let cmd = Command::DropTable { + name: "X".to_string(), + }; let facts = enrich_dsl_failure(&db, &cmd, &err).await; assert!(facts.table.is_none()); assert!(facts.column.is_none()); diff --git a/tests/it/help_command.rs b/tests/it/help_command.rs index b455f60..261611e 100644 --- a/tests/it/help_command.rs +++ b/tests/it/help_command.rs @@ -11,7 +11,7 @@ use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use rdbms_playground::app::App; -use rdbms_playground::dsl::{parse_command, AppCommand, Command}; +use rdbms_playground::dsl::{AppCommand, Command, parse_command}; use rdbms_playground::event::AppEvent; const fn key(code: KeyCode) -> AppEvent { diff --git a/tests/it/iteration2_persistence.rs b/tests/it/iteration2_persistence.rs index 0260113..026a98a 100644 --- a/tests/it/iteration2_persistence.rs +++ b/tests/it/iteration2_persistence.rs @@ -14,9 +14,7 @@ use std::path::Path; use rdbms_playground::db::Database; use rdbms_playground::dsl::{ColumnSpec, ReferentialAction, RowFilter, Type, Value}; use rdbms_playground::persistence::Persistence; -use rdbms_playground::project::{ - self, DATA_DIR, PROJECT_YAML, -}; +use rdbms_playground::project::{self, DATA_DIR, PROJECT_YAML}; fn tempdir() -> tempfile::TempDir { tempfile::tempdir().expect("create tempdir") @@ -33,9 +31,7 @@ fn rt() -> tokio::runtime::Runtime { /// `Database` (with persistence wired) plus the path so the /// test can inspect on-disk state. The project is held alive /// implicitly via the leaked `TempDir` returned alongside. -fn open_project( - data: &tempfile::TempDir, -) -> (project::Project, Database, std::path::PathBuf) { +fn open_project(data: &tempfile::TempDir) -> (project::Project, Database, std::path::PathBuf) { let project = project::open_or_create(None, Some(data.path())).expect("open project"); let path = project.path().to_path_buf(); let persistence = Persistence::new(path.clone()); @@ -72,7 +68,10 @@ fn create_table_writes_yaml_and_history() { }); let yaml = read_yaml(&path); - assert!(yaml.contains("- name: Customers"), "yaml missing table:\n{yaml}"); + assert!( + yaml.contains("- name: Customers"), + "yaml missing table:\n{yaml}" + ); assert!(yaml.contains("primary_key: [id]"), "yaml: {yaml}"); assert!(yaml.contains("type: serial"), "yaml: {yaml}"); assert!(yaml.contains("type: text"), "yaml: {yaml}"); @@ -151,9 +150,15 @@ fn drop_table_removes_its_csv() { .unwrap(); }); - assert!(read_csv(&path, "Customers").is_none(), "CSV should be deleted"); + assert!( + read_csv(&path, "Customers").is_none(), + "CSV should be deleted" + ); let yaml = read_yaml(&path); - assert!(!yaml.contains("- name: Customers"), "table should be gone from yaml:\n{yaml}"); + assert!( + !yaml.contains("- name: Customers"), + "table should be gone from yaml:\n{yaml}" + ); } #[test] @@ -263,7 +268,10 @@ fn create_table_does_not_write_csv_for_empty_table() { // Schema landed in YAML. let yaml = read_yaml(&path); - assert!(yaml.contains("- name: Customers"), "yaml missing table:\n{yaml}"); + assert!( + yaml.contains("- name: Customers"), + "yaml missing table:\n{yaml}" + ); // ...but no CSV until there's data. assert!( read_csv(&path, "Customers").is_none(), @@ -394,7 +402,10 @@ fn project_yaml_carries_relationship_after_add() { }); let yaml = read_yaml(&path); - assert!(yaml.contains("- name: Customers_id_to_Orders_CustId"), "yaml: {yaml}"); + assert!( + yaml.contains("- name: Customers_id_to_Orders_CustId"), + "yaml: {yaml}" + ); assert!(yaml.contains("on_delete: cascade"), "yaml: {yaml}"); assert!(yaml.contains("on_update: no_action"), "yaml: {yaml}"); } diff --git a/tests/it/iteration3_rebuild.rs b/tests/it/iteration3_rebuild.rs index 922811d..4f3511b 100644 --- a/tests/it/iteration3_rebuild.rs +++ b/tests/it/iteration3_rebuild.rs @@ -35,11 +35,8 @@ fn rebuild_restores_schema_only_project() { let project_path = { let project = project::open_or_create(None, Some(data.path())).unwrap(); let path = project.path().to_path_buf(); - let db = Database::open_with_persistence( - project.db_path(), - Persistence::new(path.clone()), - ) - .unwrap(); + let db = Database::open_with_persistence(project.db_path(), Persistence::new(path.clone())) + .unwrap(); rt().block_on(async { db.create_table( "Customers".to_string(), @@ -89,11 +86,8 @@ fn rebuild_restores_rows_from_csv() { let project_path = { let project = project::open_or_create(None, Some(data.path())).unwrap(); let path = project.path().to_path_buf(); - let db = Database::open_with_persistence( - project.db_path(), - Persistence::new(path.clone()), - ) - .unwrap(); + let db = Database::open_with_persistence(project.db_path(), Persistence::new(path.clone())) + .unwrap(); rt().block_on(async { db.create_table( "Customers".to_string(), @@ -157,11 +151,8 @@ fn rebuild_restores_relationships_and_cascade_behaviour() { let project_path = { let project = project::open_or_create(None, Some(data.path())).unwrap(); let path = project.path().to_path_buf(); - let db = Database::open_with_persistence( - project.db_path(), - Persistence::new(path.clone()), - ) - .unwrap(); + let db = Database::open_with_persistence(project.db_path(), Persistence::new(path.clone())) + .unwrap(); rt().block_on(async { db.create_table( "Customers".to_string(), @@ -244,7 +235,11 @@ fn rebuild_restores_relationships_and_cascade_behaviour() { }) .expect("delete"); assert_eq!(result.rows_affected, 1); - assert_eq!(result.cascade.len(), 1, "expected one cascade entry: {result:?}"); + assert_eq!( + result.cascade.len(), + 1, + "expected one cascade entry: {result:?}" + ); assert_eq!(result.cascade[0].child_table, "Orders"); } @@ -256,11 +251,8 @@ fn rebuild_reports_fatal_error_on_bad_csv_row() { let project_path = { let project = project::open_or_create(None, Some(data.path())).unwrap(); let path = project.path().to_path_buf(); - let db = Database::open_with_persistence( - project.db_path(), - Persistence::new(path.clone()), - ) - .unwrap(); + let db = Database::open_with_persistence(project.db_path(), Persistence::new(path.clone())) + .unwrap(); rt().block_on(async { db.create_table( "Numbers".to_string(), @@ -303,13 +295,17 @@ fn rebuild_reports_fatal_error_on_bad_csv_row() { .unwrap(); let err = rt() .block_on(async { - db.rebuild_from_text(project.path().to_path_buf(), None).await + db.rebuild_from_text(project.path().to_path_buf(), None) + .await }) .expect_err("must fail with row-level error"); let msg = format!("{err}"); assert!(msg.contains("row 2"), "msg should name the row: {msg}"); assert!(msg.contains("Numbers"), "msg should name the table: {msg}"); - assert!(msg.contains("integer"), "msg should explain the type mismatch: {msg}"); + assert!( + msg.contains("integer"), + "msg should explain the type mismatch: {msg}" + ); } #[test] @@ -318,11 +314,8 @@ fn rebuild_preserves_created_at_from_yaml() { let project_path = { let project = project::open_or_create(None, Some(data.path())).unwrap(); let path = project.path().to_path_buf(); - let db = Database::open_with_persistence( - project.db_path(), - Persistence::new(path.clone()), - ) - .unwrap(); + let db = Database::open_with_persistence(project.db_path(), Persistence::new(path.clone())) + .unwrap(); rt().block_on(async { db.create_table( "T".to_string(), @@ -371,9 +364,7 @@ fn rebuild_preserves_created_at_from_yaml() { // Trigger any successful command so project.yaml is // rewritten from the now-rebuilt db state. rt().block_on(async { - db.describe_table("T".to_string()) - .await - .unwrap(); + db.describe_table("T".to_string()).await.unwrap(); // describe is read-only; force a rewrite by adding a column. db.add_column( "T".to_string(), @@ -400,11 +391,8 @@ fn rebuild_restores_indexes() { let project_path = { let project = project::open_or_create(None, Some(data.path())).unwrap(); let path = project.path().to_path_buf(); - let db = Database::open_with_persistence( - project.db_path(), - Persistence::new(path.clone()), - ) - .unwrap(); + let db = Database::open_with_persistence(project.db_path(), Persistence::new(path.clone())) + .unwrap(); rt().block_on(async { db.create_table( "Customers".to_string(), @@ -434,7 +422,10 @@ fn rebuild_restores_indexes() { // The index must be recorded in project.yaml — the `.db` is // a derived artifact and gets discarded next. let yaml = fs::read_to_string(project_path.join(project::PROJECT_YAML)).unwrap(); - assert!(yaml.contains("idx_email"), "yaml should record the index:\n{yaml}"); + assert!( + yaml.contains("idx_email"), + "yaml should record the index:\n{yaml}" + ); fs::remove_file(project_path.join(PLAYGROUND_DB)).unwrap(); diff --git a/tests/it/iteration4a_rebuild_command.rs b/tests/it/iteration4a_rebuild_command.rs index ed64a62..381c9fb 100644 --- a/tests/it/iteration4a_rebuild_command.rs +++ b/tests/it/iteration4a_rebuild_command.rs @@ -113,7 +113,10 @@ fn modal_swallows_unrelated_keys() { // field while the modal is up. app.update(key(KeyCode::Char('x'))); assert!(app.input.is_empty(), "modal should swallow key input"); - assert!(app.modal.is_some(), "modal still active after unrelated key"); + assert!( + app.modal.is_some(), + "modal still active after unrelated key" + ); } #[test] @@ -122,11 +125,8 @@ fn rebuild_against_populated_db_wipes_and_reloads() { let project_path = { let project = project::open_or_create(None, Some(data.path())).unwrap(); let path = project.path().to_path_buf(); - let db = Database::open_with_persistence( - project.db_path(), - Persistence::new(path.clone()), - ) - .unwrap(); + let db = Database::open_with_persistence(project.db_path(), Persistence::new(path.clone())) + .unwrap(); rt().block_on(async { db.create_table( "Customers".to_string(), @@ -156,7 +156,9 @@ fn rebuild_against_populated_db_wipes_and_reloads() { // Hand-edit the CSV to introduce a different row content. // Rebuild should pick up the edited content. let csv_path = project_path.join("data").join("Customers.csv"); - let edited = fs::read_to_string(&csv_path).unwrap().replace("Alice", "Edna"); + let edited = fs::read_to_string(&csv_path) + .unwrap() + .replace("Alice", "Edna"); fs::write(&csv_path, edited).unwrap(); // Reopen with persistence (the .db still exists but has diff --git a/tests/it/iteration4b_lifecycle_commands.rs b/tests/it/iteration4b_lifecycle_commands.rs index bcd15dd..669fc77 100644 --- a/tests/it/iteration4b_lifecycle_commands.rs +++ b/tests/it/iteration4b_lifecycle_commands.rs @@ -16,9 +16,9 @@ use rdbms_playground::app::{ App, LoadPickerEntry, LoadPickerModal, LoadPickerSubMode, Modal, PathEntryModal, PathEntryPurpose, }; -use rdbms_playground::event::AppEvent; use rdbms_playground::db::Database; use rdbms_playground::dsl::{ColumnSpec, Type}; +use rdbms_playground::event::AppEvent; use rdbms_playground::persistence::Persistence; use rdbms_playground::project::{ self, Project, ProjectKind, copy_project, safely_delete_temp_project, @@ -462,11 +462,8 @@ fn temp_with_a_table_is_no_longer_unmodified() { let data = tempdir(); let project = project::open_or_create(None, Some(data.path())).unwrap(); let path = project.path().to_path_buf(); - let db = Database::open_with_persistence( - project.db_path(), - Persistence::new(path.clone()), - ) - .unwrap(); + let db = + Database::open_with_persistence(project.db_path(), Persistence::new(path.clone())).unwrap(); let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() diff --git a/tests/it/iteration5_export_import.rs b/tests/it/iteration5_export_import.rs index 62ccee1..a273980 100644 --- a/tests/it/iteration5_export_import.rs +++ b/tests/it/iteration5_export_import.rs @@ -14,8 +14,8 @@ use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use rdbms_playground::action::Action; use rdbms_playground::app::App; use rdbms_playground::archive::{ - default_export_filename, export_project, extract_into, inspect_zip, - next_export_sequence, resolve_import_target, + default_export_filename, export_project, extract_into, inspect_zip, next_export_sequence, + resolve_import_target, }; use rdbms_playground::event::AppEvent; use rdbms_playground::project::{HISTORY_LOG, PLAYGROUND_DB, PROJECT_YAML}; @@ -295,11 +295,9 @@ fn end_to_end_export_then_import_real_project() { // Build a populated source project. let src_path = { let p = project::Project::create_named(&data.path().join("Source")).unwrap(); - let db = Database::open_with_persistence( - p.db_path(), - Persistence::new(p.path().to_path_buf()), - ) - .unwrap(); + let db = + Database::open_with_persistence(p.db_path(), Persistence::new(p.path().to_path_buf())) + .unwrap(); rt().block_on(async { db.create_table( "Customers".to_string(), @@ -362,7 +360,11 @@ fn end_to_end_export_then_import_real_project() { // Round-trip: the inserted row is back. let data_view = rt() - .block_on(async { imported_db.query_data("Customers".to_string(), None, None).await }) + .block_on(async { + imported_db + .query_data("Customers".to_string(), None, None) + .await + }) .expect("query data"); assert_eq!(data_view.rows.len(), 1); // Serial id auto-filled to 1; Name was the inserted value. diff --git a/tests/it/iteration6_resume_history.rs b/tests/it/iteration6_resume_history.rs index d4fe1d4..d4dba27 100644 --- a/tests/it/iteration6_resume_history.rs +++ b/tests/it/iteration6_resume_history.rs @@ -166,7 +166,8 @@ fn hydration_reads_both_ok_and_err_records() { let project = Project::create_temp(tmp.path()).unwrap(); let p = Persistence::new(project.path().to_path_buf()); p.append_history("create table A with pk", false).unwrap(); - p.append_history_failure("insert into A (1, 2, 3)", false).unwrap(); + p.append_history_failure("insert into A (1, 2, 3)", false) + .unwrap(); p.append_history("show data A", false).unwrap(); let entries = p.read_recent_history(10).unwrap(); assert_eq!( diff --git a/tests/it/m2n.rs b/tests/it/m2n.rs index 189c37d..b611aa2 100644 --- a/tests/it/m2n.rs +++ b/tests/it/m2n.rs @@ -8,7 +8,7 @@ use rdbms_playground::db::Database; use rdbms_playground::dsl::command::RowFilter; -use rdbms_playground::dsl::{parse_command, ColumnSpec, Command, Type, Value}; +use rdbms_playground::dsl::{ColumnSpec, Command, Type, Value, parse_command}; use rdbms_playground::persistence::Persistence; use rdbms_playground::project::{self, PLAYGROUND_DB}; @@ -22,8 +22,11 @@ fn rt() -> tokio::runtime::Runtime { fn open() -> (project::Project, Database, tempfile::TempDir) { let dir = tempfile::tempdir().expect("tempdir"); let project = project::open_or_create(None, Some(dir.path())).expect("project"); - let db = Database::open_with_persistence(project.db_path(), Persistence::new(project.path().to_path_buf())) - .expect("db"); + let db = Database::open_with_persistence( + project.db_path(), + Persistence::new(project.path().to_path_buf()), + ) + .expect("db"); (project, db, dir) } @@ -45,7 +48,10 @@ fn open_with_undo() -> (project::Project, Database, tempfile::TempDir) { async fn serial_pk_table(db: &Database, name: &str) { db.create_table( name.to_string(), - vec![ColumnSpec::new("id", Type::Serial), ColumnSpec::new("label", Type::Text)], + vec![ + ColumnSpec::new("id", Type::Serial), + ColumnSpec::new("label", Type::Text), + ], vec!["id".to_string()], None, ) @@ -84,7 +90,9 @@ fn parses_with_as_name() { match parse_command("create m:n relationship from Students to Courses as Enrollments") .expect("parses") { - Command::CreateM2nRelationship { name, .. } => assert_eq!(name.as_deref(), Some("Enrollments")), + Command::CreateM2nRelationship { name, .. } => { + assert_eq!(name.as_deref(), Some("Enrollments")) + } other => panic!("expected CreateM2nRelationship, got {other:?}"), } } @@ -104,12 +112,21 @@ fn generates_junction_with_compound_pk_and_two_enforced_fks() { // Auto-named `Students_Courses` exists. let tables = db.list_tables().await.unwrap(); - 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. - let desc = db.describe_table("Students_Courses".to_string()).await.unwrap(); - let cols: Vec<(&str, bool)> = - desc.columns.iter().map(|c| (c.name.as_str(), c.primary_key)).collect(); + let desc = db + .describe_table("Students_Courses".to_string()) + .await + .unwrap(); + let cols: Vec<(&str, bool)> = desc + .columns + .iter() + .map(|c| (c.name.as_str(), c.primary_key)) + .collect(); assert_eq!( cols, vec![("Students_id", true), ("Courses_id", true)], @@ -124,7 +141,10 @@ fn generates_junction_with_compound_pk_and_two_enforced_fks() { db.insert( "Students_Courses".to_string(), Some(vec!["Students_id".to_string(), "Courses_id".to_string()]), - vec![Value::Number("1".to_string()), Value::Number("1".to_string())], + vec![ + Value::Number("1".to_string()), + Value::Number("1".to_string()), + ], None, ) .await @@ -134,21 +154,33 @@ fn generates_junction_with_compound_pk_and_two_enforced_fks() { .insert( "Students_Courses".to_string(), Some(vec!["Students_id".to_string(), "Courses_id".to_string()]), - vec![Value::Number("1".to_string()), Value::Number("1".to_string())], + vec![ + Value::Number("1".to_string()), + Value::Number("1".to_string()), + ], None, ) .await; - assert!(dup.is_err(), "duplicate (Students_id, Courses_id) must be refused"); + assert!( + dup.is_err(), + "duplicate (Students_id, Courses_id) must be refused" + ); // A link to a non-existent parent is refused by the FK. let orphan = db .insert( "Students_Courses".to_string(), Some(vec!["Students_id".to_string(), "Courses_id".to_string()]), - vec![Value::Number("1".to_string()), Value::Number("99".to_string())], + vec![ + Value::Number("1".to_string()), + Value::Number("99".to_string()), + ], None, ) .await; - assert!(orphan.is_err(), "link to a non-existent Course must be refused"); + assert!( + orphan.is_err(), + "link to a non-existent Course must be refused" + ); }); } @@ -167,7 +199,10 @@ fn as_name_overrides_the_junction_table_name() { .await .expect("create m:n as Enrollments"); let tables = db.list_tables().await.unwrap(); - assert!(tables.contains(&"Enrollments".to_string()), "tables: {tables:?}"); + assert!( + tables.contains(&"Enrollments".to_string()), + "tables: {tables:?}" + ); assert!(!tables.contains(&"Students_Courses".to_string())); }); } @@ -179,7 +214,10 @@ fn compound_parent_pk_contributes_one_fk_column_each() { // Sections has a 2-column PK (course_id, term). db.create_table( "Sections".to_string(), - vec![ColumnSpec::new("course_id", Type::Int), ColumnSpec::new("term", Type::Int)], + vec![ + ColumnSpec::new("course_id", Type::Int), + ColumnSpec::new("term", Type::Int), + ], vec!["course_id".to_string(), "term".to_string()], None, ) @@ -191,11 +229,20 @@ fn compound_parent_pk_contributes_one_fk_column_each() { .await .expect("create m:n"); - let desc = db.describe_table("Students_Sections".to_string()).await.unwrap(); + let desc = db + .describe_table("Students_Sections".to_string()) + .await + .unwrap(); let names: Vec<&str> = desc.columns.iter().map(|c| c.name.as_str()).collect(); - assert_eq!(names, vec!["Students_id", "Sections_course_id", "Sections_term"]); + assert_eq!( + names, + vec!["Students_id", "Sections_course_id", "Sections_term"] + ); // All three form the compound PK. - assert!(desc.columns.iter().all(|c| c.primary_key), "all columns are PK: {names:?}"); + assert!( + desc.columns.iter().all(|c| c.primary_key), + "all columns are PK: {names:?}" + ); }); } @@ -213,16 +260,28 @@ fn deleting_a_parent_cascades_to_the_junction() { db.insert( "Students_Courses".to_string(), Some(vec!["Students_id".to_string(), "Courses_id".to_string()]), - vec![Value::Number("1".to_string()), Value::Number("1".to_string())], + vec![ + Value::Number("1".to_string()), + Value::Number("1".to_string()), + ], None, ) .await .unwrap(); // Deleting the student cascades to the junction (ON DELETE CASCADE). - db.delete("Students".to_string(), RowFilter::AllRows, None).await.unwrap(); - let rows = db.query_data("Students_Courses".to_string(), None, None).await.unwrap(); - assert!(rows.rows.is_empty(), "junction rows should cascade-delete, got {:?}", rows.rows); + db.delete("Students".to_string(), RowFilter::AllRows, 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 + ); }); } @@ -242,15 +301,26 @@ fn create_m2n_is_one_undo_step() { ) .await .unwrap(); - assert!(db.list_tables().await.unwrap().contains(&"Students_Courses".to_string())); + assert!( + db.list_tables() + .await + .unwrap() + .contains(&"Students_Courses".to_string()) + ); // One undo removes the junction table AND both relationships. db.undo().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). 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" + ); }); } @@ -265,7 +335,10 @@ fn self_referential_m2n_is_refused() { .create_m2n_relationship("Users".to_string(), "Users".to_string(), None, None) .await .expect_err("self m:n must be refused"); - assert!(format!("{err}").contains("two different tables"), "got: {err}"); + assert!( + format!("{err}").contains("two different tables"), + "got: {err}" + ); }); } @@ -275,11 +348,19 @@ fn missing_parent_table_is_refused() { rt().block_on(async { serial_pk_table(&db, "Students").await; let err = db - .create_m2n_relationship("Students".to_string(), "Nonexistent".to_string(), None, None) + .create_m2n_relationship( + "Students".to_string(), + "Nonexistent".to_string(), + None, + None, + ) .await .expect_err("a missing parent table must be refused"); // The standard "no such table" guard (require_canonical_table). - assert!(format!("{err}").to_lowercase().contains("no such table"), "got: {err}"); + assert!( + format!("{err}").to_lowercase().contains("no such table"), + "got: {err}" + ); }); } @@ -297,7 +378,10 @@ fn junction_name_collision_is_refused() { .create_m2n_relationship("Students".to_string(), "Courses".to_string(), None, None) .await .expect_err("a junction-name collision must be refused"); - assert!(format!("{err}").to_lowercase().contains("exist"), "got: {err}"); + assert!( + format!("{err}").to_lowercase().contains("exist"), + "got: {err}" + ); }); } @@ -314,15 +398,26 @@ fn the_junction_can_be_renamed() { db.create_m2n_relationship("Students".to_string(), "Courses".to_string(), None, None) .await .unwrap(); - db.rename_table("Students_Courses".to_string(), "Enrollments".to_string(), None) - .await - .expect("rename the junction"); + db.rename_table( + "Students_Courses".to_string(), + "Enrollments".to_string(), + None, + ) + .await + .expect("rename the junction"); let tables = db.list_tables().await.unwrap(); - assert!(tables.contains(&"Enrollments".to_string()), "tables: {tables:?}"); + assert!( + tables.contains(&"Enrollments".to_string()), + "tables: {tables:?}" + ); assert!(!tables.contains(&"Students_Courses".to_string())); // Both relationships survive the rename (rebuild-preserving). let desc = db.describe_table("Enrollments".to_string()).await.unwrap(); - assert_eq!(desc.outbound_relationships.len(), 2, "FKs preserved across rename"); + assert_eq!( + desc.outbound_relationships.len(), + 2, + "FKs preserved across rename" + ); }); } @@ -355,16 +450,33 @@ fn junction_survives_save_and_rebuild() { // Discard the derived .db so the next open rebuilds from text. std::fs::remove_file(project_path.join(PLAYGROUND_DB)).unwrap(); let project = project::Project::open(&project_path).unwrap(); - let db = - Database::open_with_persistence(project.db_path(), Persistence::new(project.path().to_path_buf())) - .unwrap(); + let db = Database::open_with_persistence( + project.db_path(), + Persistence::new(project.path().to_path_buf()), + ) + .unwrap(); rt().block_on(async { - 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(); - assert!(tables.contains(&"Students_Courses".to_string()), "junction survived: {tables:?}"); - let desc = db.describe_table("Students_Courses".to_string()).await.unwrap(); - assert_eq!(desc.outbound_relationships.len(), 2, "both FKs reconstructed"); - assert!(desc.columns.iter().all(|c| c.primary_key), "compound PK reconstructed"); + assert!( + tables.contains(&"Students_Courses".to_string()), + "junction survived: {tables:?}" + ); + let desc = db + .describe_table("Students_Courses".to_string()) + .await + .unwrap(); + assert_eq!( + desc.outbound_relationships.len(), + 2, + "both FKs reconstructed" + ); + assert!( + desc.columns.iter().all(|c| c.primary_key), + "compound PK reconstructed" + ); }); } @@ -387,7 +499,12 @@ fn as_an_internal_name_is_refused() { .await .expect_err("an internal junction name must be refused"); assert!(format!("{err}").contains("no such table"), "got: {err}"); - assert!(!db.list_tables().await.unwrap().contains(&"__rdbms_evil".to_string())); + assert!( + !db.list_tables() + .await + .unwrap() + .contains(&"__rdbms_evil".to_string()) + ); }); } @@ -442,7 +559,10 @@ fn read_all_relationships_returns_the_junction_relationships() { ); // Both have the junction (Students_Courses) as their child. for r in &rels { - assert_eq!(r.child_table, "Students_Courses", "child is the junction: {r:?}"); + assert_eq!( + r.child_table, "Students_Courses", + "child is the junction: {r:?}" + ); } // One points back to each parent. let parents: std::collections::BTreeSet<&str> = diff --git a/tests/it/main.rs b/tests/it/main.rs index cbc6d4b..8383378 100644 --- a/tests/it/main.rs +++ b/tests/it/main.rs @@ -24,6 +24,7 @@ mod parse_error_pedagogy; mod project_lifecycle; mod replay_command; mod seed; +mod show_list; mod sql_alter_table; mod sql_create_index; mod sql_create_table; @@ -34,6 +35,5 @@ mod sql_drop_table; mod sql_insert; mod sql_select; mod sql_update; -mod show_list; mod undo_snapshots; mod walking_skeleton; diff --git a/tests/it/parse_error_pedagogy.rs b/tests/it/parse_error_pedagogy.rs index 919c07a..47a9588 100644 --- a/tests/it/parse_error_pedagogy.rs +++ b/tests/it/parse_error_pedagogy.rs @@ -54,10 +54,7 @@ fn error_lines_for(input: &str) -> Vec { } fn dump(input: &str, lines: &[String]) -> String { - format!( - "INPUT: {input:?}\nERROR LINES:\n{}", - lines.join("\n"), - ) + format!("INPUT: {input:?}\nERROR LINES:\n{}", lines.join("\n"),) } /// The simple-mode near-miss matrix (ADR-0042 §1). Each row is a @@ -71,57 +68,228 @@ fn near_miss_matrix_simple_mode() { // app-lifecycle arg errors. The arg-less commands all reject // trailing junk with "expected end of input" + their usage // (audited 2026-06-05); locked here as regression insurance. - ("quit now", &["after `quit`, expected end of input", " quit"]), + ( + "quit now", + &["after `quit`, expected end of input", " quit"], + ), // `help` now takes an optional single-word topic (H3), so // `help foo` parses (topic lookup); only a *multi-word* // topic is the near-miss that rejects trailing junk. - ("help foo bar", &["after `help foo`, expected end of input", "help []"]), - ("rebuild now", &["after `rebuild`, expected end of input", " rebuild"]), + ( + "help foo bar", + &[ + "after `help foo`, expected end of input", + "help []", + ], + ), + ( + "rebuild now", + &["after `rebuild`, expected end of input", " rebuild"], + ), ("new foo", &["after `new`, expected end of input", " new"]), - ("load foo", &["after `load`, expected end of input", " load"]), - ("undo foo", &["after `undo`, expected end of input", " undo"]), - ("redo foo", &["after `redo`, expected end of input", " redo"]), - ("export foo bar", &["after `export foo`, expected end of input", "export []"]), - ("import a b c", &["after `import a`, expected end of input", "import "]), - ("save sideways", &["after `save`, expected end of input", "save | save as"]), - ("mode sideways", &["unknown mode 'sideways'", "mode simple | mode advanced"]), - ("messages louder", &["unknown messages mode 'louder'", "messages short"]), - ("copy everything", &["unknown copy target 'everything'", "copy all"]), + ( + "load foo", + &["after `load`, expected end of input", " load"], + ), + ( + "undo foo", + &["after `undo`, expected end of input", " undo"], + ), + ( + "redo foo", + &["after `redo`, expected end of input", " redo"], + ), + ( + "export foo bar", + &[ + "after `export foo`, expected end of input", + "export []", + ], + ), + ( + "import a b c", + &[ + "after `import a`, expected end of input", + "import ", + ], + ), + ( + "save sideways", + &["after `save`, expected end of input", "save | save as"], + ), + ( + "mode sideways", + &["unknown mode 'sideways'", "mode simple | mode advanced"], + ), + ( + "messages louder", + &["unknown messages mode 'louder'", "messages short"], + ), + ( + "copy everything", + &["unknown copy target 'everything'", "copy all"], + ), // DDL bare + missing-slot - ("create", &["after `create`, expected `table`", "create table with pk"]), - ("create table", &["after `create table`, expected identifier", "create table with pk"]), - ("create table T", &["with pk", "create table with pk"]), + ( + "create", + &[ + "after `create`, expected `table`", + "create table with pk", + ], + ), + ( + "create table", + &[ + "after `create table`, expected identifier", + "create table with pk", + ], + ), + ( + "create table T", + &["with pk", "create table with pk"], + ), // G1: relationship cardinality reads as the named construct. - ("add", &["after `add`, expected `column`, `1:n relationship`", "add 1:n relationship"]), - ("drop table", &["after `drop table`, expected table name", "drop table "]), - ("add column", &["after `add column`, expected table name", "add column [to] [table]"]), - ("rename", &["after `rename`, expected `column`", "rename column [in] [table]"]), - ("rename column", &["after `rename column`, expected table name", "rename column [in] [table]"]), - ("change", &["after `change`, expected `column`", "change column [in] [table]"]), - ("change column", &["after `change column`, expected table name", "change column [in] [table]"]), + ( + "add", + &[ + "after `add`, expected `column`, `1:n relationship`", + "add 1:n relationship", + ], + ), + ( + "drop table", + &[ + "after `drop table`, expected table name", + "drop table ", + ], + ), + ( + "add column", + &[ + "after `add column`, expected table name", + "add column [to] [table]", + ], + ), + ( + "rename", + &[ + "after `rename`, expected `column`", + "rename column [in] [table]", + ], + ), + ( + "rename column", + &[ + "after `rename column`, expected table name", + "rename column [in] [table]", + ], + ), + ( + "change", + &[ + "after `change`, expected `column`", + "change column [in] [table]", + ], + ), + ( + "change column", + &[ + "after `change column`, expected table name", + "change column [in] [table]", + ], + ), // data bare + missing-clause - ("insert", &["after `insert`, expected `into`", "insert into
"]), - ("insert into", &["after `insert into`, expected table name", "insert into
"]), - ("insert into T", &["after `insert into T`, expected `values` or `(`", "insert into
"]), - ("update", &["after `update`, expected table name", "update
set"]), - ("update T", &["after `update T`, expected `set`", "update
set"]), - ("update T set x=1", &["expected `where` or `--all-rows`", "update
set"]), - ("delete", &["after `delete`, expected `from`", "delete from
"]), - ("delete from", &["after `delete from`, expected table name", "delete from
"]), - ("delete from T", &["expected `where` or `--all-rows`", "delete from
"]), - ("seed", &["after `seed`, expected table name", "seed
[count]"]), + ( + "insert", + &["after `insert`, expected `into`", "insert into
"], + ), + ( + "insert into", + &[ + "after `insert into`, expected table name", + "insert into
", + ], + ), + ( + "insert into T", + &[ + "after `insert into T`, expected `values` or `(`", + "insert into
", + ], + ), + ( + "update", + &["after `update`, expected table name", "update
set"], + ), + ( + "update T", + &["after `update T`, expected `set`", "update
set"], + ), + ( + "update T set x=1", + &["expected `where` or `--all-rows`", "update
set"], + ), + ( + "delete", + &["after `delete`, expected `from`", "delete from
"], + ), + ( + "delete from", + &[ + "after `delete from`, expected table name", + "delete from
", + ], + ), + ( + "delete from T", + &["expected `where` or `--all-rows`", "delete from
"], + ), + ( + "seed", + &["after `seed`, expected table name", "seed
[count]"], + ), // Phase 2 (ADR-0048 D2/D1): malformed `set` clause + column-fill. - ("seed T set", &["after `seed T set`, expected column name", "seed
."]), + ( + "seed T set", + &[ + "after `seed T set`, expected column name", + "seed
.", + ], + ), ( "seed T set role", - &["after `seed T set role`, expected `=`, `in`, `between`, or `as`", "seed
."], + &[ + "after `seed T set role`, expected `=`, `in`, `between`, or `as`", + "seed
.", + ], + ), + ( + "seed T.", + &[ + "after `seed T.`, expected column name", + "seed
.", + ], + ), + ( + "replay", + &[ + "after `replay`, expected string literal or path", + "replay ", + ], + ), + ( + "explain", + &[ + "after `explain`, expected `show`, `update`, or `delete`", + "explain show data", + ], ), - ("seed T.", &["after `seed T.`, expected column name", "seed
."]), - ("replay", &["after `replay`, expected string literal or path", "replay "]), - ("explain", &["after `explain`, expected `show`, `update`, or `delete`", "explain show data"]), // advanced-only entry word typed in simple mode → "this is SQL" rail ("select * from T", &["`select` is SQL", "mode advanced"]), - ("alter table T add column c int", &["`alter` is SQL", "mode advanced"]), + ( + "alter table T add column c int", + &["`alter` is SQL", "mode advanced"], + ), ]; for (input, needles) in matrix { let lines = error_lines_for(input); @@ -164,26 +332,160 @@ fn near_miss_matrix_committed_multiforms() { // (input, advanced?, required-substrings) let matrix: &[(&str, bool, &[&str])] = &[ // add / drop multi-forms (simple) - ("add index", false, &["after `add index`, expected `on` or `as`", "add index [as ] on"]), - ("add index on T", false, &["after `add index on T`, expected `(`", "add index [as ] on"]), - ("add constraint", false, &["after `add constraint`, expected `not`, `unique`, `default`, or `check`", "add constraint not null to"]), - ("add constraint not null", false, &["after `add constraint not null`, expected `to`", "add constraint not null to"]), - ("add 1:n relationship", false, &["after `add 1:n relationship`, expected `from` or `as`", "add 1:n relationship"]), - ("add 1:n relationship from", false, &["after `add 1:n relationship from`, expected table name", "from ."]), - ("drop constraint", false, &["after `drop constraint`, expected `not`, `unique`, `default`, or `check`", "drop constraint (not null"]), - ("drop constraint not null", false, &["after `drop constraint not null`, expected `from`", "drop constraint (not null"]), - ("drop index", false, &["after `drop index`, expected `on` or index name", "drop index ", "drop index on
"]), - ("drop index on T", false, &["after `drop index on T`, expected `(`", "drop index on
"]), - ("drop relationship", false, &["after `drop relationship`, expected `from` or relationship name", "drop relationship "]), - ("show table", false, &["after `show table`, expected table name", "show table
"]), - ("show relationship", false, &["after `show relationship`, expected relationship name", "show relationship "]), - ("show index", false, &["after `show index`, expected index name", "show index "]), - ("change column in table T: c", false, &["after `change column in table T: c`, expected `(`", "change column [in] [table]"]), + ( + "add index", + false, + &[ + "after `add index`, expected `on` or `as`", + "add index [as ] on", + ], + ), + ( + "add index on T", + false, + &[ + "after `add index on T`, expected `(`", + "add index [as ] on", + ], + ), + ( + "add constraint", + false, + &[ + "after `add constraint`, expected `not`, `unique`, `default`, or `check`", + "add constraint not null to", + ], + ), + ( + "add constraint not null", + false, + &[ + "after `add constraint not null`, expected `to`", + "add constraint not null to", + ], + ), + ( + "add 1:n relationship", + false, + &[ + "after `add 1:n relationship`, expected `from` or `as`", + "add 1:n relationship", + ], + ), + ( + "add 1:n relationship from", + false, + &[ + "after `add 1:n relationship from`, expected table name", + "from .", + ], + ), + ( + "drop constraint", + false, + &[ + "after `drop constraint`, expected `not`, `unique`, `default`, or `check`", + "drop constraint (not null", + ], + ), + ( + "drop constraint not null", + false, + &[ + "after `drop constraint not null`, expected `from`", + "drop constraint (not null", + ], + ), + ( + "drop index", + false, + &[ + "after `drop index`, expected `on` or index name", + "drop index ", + "drop index on
", + ], + ), + ( + "drop index on T", + false, + &[ + "after `drop index on T`, expected `(`", + "drop index on
", + ], + ), + ( + "drop relationship", + false, + &[ + "after `drop relationship`, expected `from` or relationship name", + "drop relationship ", + ], + ), + ( + "show table", + false, + &[ + "after `show table`, expected table name", + "show table
", + ], + ), + ( + "show relationship", + false, + &[ + "after `show relationship`, expected relationship name", + "show relationship ", + ], + ), + ( + "show index", + false, + &[ + "after `show index`, expected index name", + "show index ", + ], + ), + ( + "change column in table T: c", + false, + &[ + "after `change column in table T: c`, expected `(`", + "change column [in] [table]", + ], + ), // advanced committed multi-forms - ("create index on", true, &["after `create index on`, expected table name", "create [unique] index"]), - ("create unique index", true, &["after `create unique index`, expected `on`, identifier, or `if`", "create [unique] index"]), - ("alter table T add", true, &["after `alter table T add`, expected `column`, `constraint`, `check`, `unique`, `foreign`, or `primary`", "alter table
add column"]), - ("alter table T drop", true, &["after `alter table T drop`, expected `column` or `constraint`", "alter table
drop column"]), + ( + "create index on", + true, + &[ + "after `create index on`, expected table name", + "create [unique] index", + ], + ), + ( + "create unique index", + true, + &[ + "after `create unique index`, expected `on`, identifier, or `if`", + "create [unique] index", + ], + ), + ( + "alter table T add", + true, + &[ + "after `alter table T add`, expected `column`, `constraint`, `check`, `unique`, `foreign`, or `primary`", + "alter table
add column", + ], + ), + ( + "alter table T drop", + true, + &[ + "after `alter table T drop`, expected `column` or `constraint`", + "alter table
drop column", + ], + ), ]; for (input, advanced, needles) in matrix { let lines = if *advanced { @@ -265,7 +567,10 @@ fn advanced_mode_usage_block_shows_sql_and_dsl_forms() { // (mode-primary first). let sql_at = joined.find("create table [if not exists]").unwrap(); let dsl_at = joined.find("create table with pk").unwrap(); - assert!(sql_at < dsl_at, "SQL form should precede the DSL form\n{dump_msg}"); + assert!( + sql_at < dsl_at, + "SQL form should precede the DSL form\n{dump_msg}" + ); } #[test] @@ -307,23 +612,94 @@ fn advanced_cross_join_with_on_teaches_no_on_clause() { fn near_miss_matrix_advanced_mode() { let matrix: &[(&str, &[&str])] = &[ // SQL select / with (G2, G4) - ("select", &["expected a projection: `*`, a column, or an expression", "select (* |"]), - ("select * from", &["after `select * from`, expected table name", "select (* |"]), - ("with", &["after `with`, expected identifier or `recursive`", "with [recursive]", "as ("]), + ( + "select", + &[ + "expected a projection: `*`, a column, or an expression", + "select (* |", + ], + ), + ( + "select * from", + &["after `select * from`, expected table name", "select (* |"], + ), + ( + "with", + &[ + "after `with`, expected identifier or `recursive`", + "with [recursive]", + "as (", + ], + ), // create / drop / alter — SQL forms AND the still-valid DSL // fallback forms, SQL-primary first (G3). - ("create", &["after `create`, expected `table`", "create table [if not exists]", "create [unique] index", "create table with pk"]), - ("create table", &["after `create table`, expected identifier or `if`", "create table [if not exists]"]), - ("create index", &["after `create index`, expected `on`", "create [unique] index"]), - ("drop", &["after `drop`, expected `table`", "drop table [if exists]", "drop column [from]", "drop relationship"]), - ("alter", &["after `alter`, expected `table`", "alter table
add column"]), - ("alter table T", &["expected `add`, `drop`, `rename`, or `alter`", "alter table
"]), + ( + "create", + &[ + "after `create`, expected `table`", + "create table [if not exists]", + "create [unique] index", + "create table with pk", + ], + ), + ( + "create table", + &[ + "after `create table`, expected identifier or `if`", + "create table [if not exists]", + ], + ), + ( + "create index", + &[ + "after `create index`, expected `on`", + "create [unique] index", + ], + ), + ( + "drop", + &[ + "after `drop`, expected `table`", + "drop table [if exists]", + "drop column [from]", + "drop relationship", + ], + ), + ( + "alter", + &[ + "after `alter`, expected `table`", + "alter table
add column", + ], + ), + ( + "alter table T", + &[ + "expected `add`, `drop`, `rename`, or `alter`", + "alter table
", + ], + ), // shared insert/update/delete — must show usage, not the // available-commands fallback (regression guard for the // empty-usage_ids SQL nodes). - ("insert into T", &["after `insert into T`, expected `values`, `with`, `select`, or `(`", "insert into
"]), - ("update T", &["after `update T`, expected `set`", "update
set"]), - ("delete from", &["after `delete from`, expected table name", "delete from
"]), + ( + "insert into T", + &[ + "after `insert into T`, expected `values`, `with`, `select`, or `(`", + "insert into
", + ], + ), + ( + "update T", + &["after `update T`, expected `set`", "update
set"], + ), + ( + "delete from", + &[ + "after `delete from`, expected table name", + "delete from
", + ], + ), ]; for (input, needles) in matrix { let lines = advanced_error_lines_for(input); @@ -365,7 +741,9 @@ fn with_alone_renders_cte_usage_not_select() { .collect(); let dump_msg = dump("with", &lines); assert!( - lines.iter().any(|l| l.trim_start().starts_with("with ") && l.contains("as (")), + lines + .iter() + .any(|l| l.trim_start().starts_with("with ") && l.contains("as (")), "missing CTE-specific `with … as (…)` usage template\n{dump_msg}", ); } @@ -383,7 +761,9 @@ fn create_alone_renders_create_table_usage() { "missing usage: header\n{dump_msg}", ); assert!( - lines.iter().any(|l| l.contains("create table") && l.contains("with pk")), + lines + .iter() + .any(|l| l.contains("create table") && l.contains("with pk")), "missing create_table usage template\n{dump_msg}", ); } @@ -464,8 +844,7 @@ fn unknown_command_falls_back_to_available_commands_list() { .unwrap_or_else(|| panic!("missing available commands line\n{dump_msg}")); // The list must include all ten command-entry keywords. for cmd in [ - "add", "change", "create", "delete", "drop", "insert", - "rename", "replay", "show", "update", + "add", "change", "create", "delete", "drop", "insert", "rename", "replay", "show", "update", ] { assert!( available.contains(&format!("`{cmd}`")), @@ -543,8 +922,3 @@ fn caret_aligns_under_offending_token() { "caret should sit at column 9 (under `f` of `frobulate` after the `running: ` prefix); got {leading_spaces} spaces in {caret:?}", ); } - - - - - diff --git a/tests/it/project_lifecycle.rs b/tests/it/project_lifecycle.rs index a8bed7b..ad5bd71 100644 --- a/tests/it/project_lifecycle.rs +++ b/tests/it/project_lifecycle.rs @@ -22,14 +22,16 @@ fn tempdir() -> tempfile::TempDir { #[test] fn no_args_creates_temp_project_under_data_root() { let data = tempdir(); - let project = project::open_or_create(None, Some(data.path())) - .expect("open_or_create with empty CLI"); + let project = + project::open_or_create(None, Some(data.path())).expect("open_or_create with empty CLI"); let path = project.path(); assert!(path.exists(), "project dir should exist"); assert!(path.starts_with(data.path())); assert_eq!( - path.parent().and_then(|p| p.file_name()).map(|s| s.to_string_lossy().into_owned()), + path.parent() + .and_then(|p| p.file_name()) + .map(|s| s.to_string_lossy().into_owned()), Some(PROJECTS_SUBDIR.to_string()), ); @@ -96,8 +98,7 @@ fn positional_path_opens_existing_project() { // Now drive open_or_create with the path as if it were a // CLI positional argument. - let project = project::open_or_create(Some(&path), None) - .expect("open via positional path"); + let project = project::open_or_create(Some(&path), None).expect("open via positional path"); assert_eq!(project.path(), path); } @@ -142,7 +143,10 @@ fn data_dir_override_does_not_touch_default_os_dir() { assert!(p1_path.starts_with(data.path())); assert!(p2_path.starts_with(data.path())); - assert_ne!(p1_path, p2_path, "two temp projects must have distinct names"); + assert_ne!( + p1_path, p2_path, + "two temp projects must have distinct names" + ); } #[test] @@ -167,11 +171,18 @@ fn db_persists_across_open_close_cycles() { db.create_table( "Customers".to_string(), vec![ - rdbms_playground::dsl::ColumnSpec::new("id".to_string(), rdbms_playground::dsl::Type::Serial), - rdbms_playground::dsl::ColumnSpec::new("Name".to_string(), rdbms_playground::dsl::Type::Text), + rdbms_playground::dsl::ColumnSpec::new( + "id".to_string(), + rdbms_playground::dsl::Type::Serial, + ), + rdbms_playground::dsl::ColumnSpec::new( + "Name".to_string(), + rdbms_playground::dsl::Type::Text, + ), ], vec!["id".to_string()], - None) + None, + ) .await .expect("create_table"); }); @@ -187,7 +198,9 @@ fn db_persists_across_open_close_cycles() { .enable_all() .build() .unwrap(); - let tables = rt.block_on(async { db.list_tables().await }).expect("list_tables"); + let tables = rt + .block_on(async { db.list_tables().await }) + .expect("list_tables"); assert!(tables.iter().any(|t| t == "Customers"), "got: {tables:?}"); // Sanity: the project.yaml and history.log are still empty diff --git a/tests/it/replay_command.rs b/tests/it/replay_command.rs index d5ccb33..755edfb 100644 --- a/tests/it/replay_command.rs +++ b/tests/it/replay_command.rs @@ -47,8 +47,7 @@ fn tempdir() -> tempfile::TempDir { /// harness — most tests only need to write a script file and /// call `run_replay`. fn open_project_db(data_root: &Path) -> (project::Project, Database) { - let project = project::open_or_create(None, Some(data_root)) - .expect("open_or_create"); + let project = project::open_or_create(None, Some(data_root)).expect("open_or_create"); let db = Database::open_with_persistence( project.db_path(), Persistence::new(project.path().to_path_buf()), @@ -132,9 +131,7 @@ fn replay_three_lines_dispatches_three_commands() { insert into T (1, 'Alice')\n", ); - let events = rt().block_on(async { - run_replay(&db, project.path(), "seed.commands").await - }); + let events = rt().block_on(async { run_replay(&db, project.path(), "seed.commands").await }); assert_completed(&events, 3); // The dispatched commands actually mutated state. @@ -167,8 +164,7 @@ fn replay_of_actual_history_log_runs_ok_commands_and_skips_err() { 2026-05-24T10:00:03Z|ok|insert into T (id, v) values (1, 'alpha')\n", ); - let events = - rt().block_on(async { run_replay(&db, project.path(), "history.log").await }); + let events = rt().block_on(async { run_replay(&db, project.path(), "history.log").await }); // Three `ok` records replayed; the `err` record is skipped (not // counted, not a failure). assert_completed(&events, 3); @@ -215,14 +211,21 @@ fn replay_skips_app_lifecycle_commands_silently() { 2026-05-24T10:00:13Z|ok|add column T: v (text)\n\ 2026-05-24T10:00:14Z|ok|insert into T (id, v) values (1, 'alpha')\n", ); - let events = - rt().block_on(async { run_replay(&db, project.path(), "history.log").await }); + let events = rt().block_on(async { run_replay(&db, project.path(), "history.log").await }); // Three data/schema commands ran; every app-lifecycle line was // skipped silently (no panic, no abort, no warnings, no quit). match events.last().expect("event") { - AppEvent::ReplayCompleted { count, warnings, .. } => { - assert_eq!(*count, 3, "only the 3 write commands ran; events: {events:?}"); - assert!(warnings.is_empty(), "these skips are silent; got {warnings:?}"); + AppEvent::ReplayCompleted { + count, warnings, .. + } => { + assert_eq!( + *count, 3, + "only the 3 write commands ran; events: {events:?}" + ); + assert!( + warnings.is_empty(), + "these skips are silent; got {warnings:?}" + ); } other => panic!("expected ReplayCompleted, got {other:?}"), } @@ -251,10 +254,11 @@ fn replay_skips_import_with_a_warning() { "2026-05-24T10:00:00Z|ok|create table T with pk id(int)\n\ 2026-05-24T10:00:01Z|ok|import shared.zip as Imported\n", ); - let events = - rt().block_on(async { run_replay(&db, project.path(), "history.log").await }); + let events = rt().block_on(async { run_replay(&db, project.path(), "history.log").await }); match events.last().expect("event") { - AppEvent::ReplayCompleted { count, warnings, .. } => { + AppEvent::ReplayCompleted { + count, warnings, .. + } => { assert_eq!(*count, 1, "only the create ran; events: {events:?}"); assert!( warnings.iter().any(|w| w.contains("import shared.zip")), @@ -282,9 +286,7 @@ fn replay_skips_blank_lines_and_comments() { \n", ); - let events = rt().block_on(async { - run_replay(&db, project.path(), "seed.commands").await - }); + let events = rt().block_on(async { run_replay(&db, project.path(), "seed.commands").await }); // Only two non-blank, non-comment lines. assert_completed(&events, 2); } @@ -295,9 +297,7 @@ fn replay_empty_file_completes_with_zero_commands() { let (project, db) = open_project_db(data.path()); write_script(project.path(), "empty.commands", ""); - let events = rt().block_on(async { - run_replay(&db, project.path(), "empty.commands").await - }); + let events = rt().block_on(async { run_replay(&db, project.path(), "empty.commands").await }); assert_completed(&events, 0); } @@ -311,9 +311,8 @@ fn replay_only_comments_completes_with_zero_commands() { "# just\n# comments\n\n", ); - let events = rt().block_on(async { - run_replay(&db, project.path(), "comments.commands").await - }); + let events = + rt().block_on(async { run_replay(&db, project.path(), "comments.commands").await }); assert_completed(&events, 0); } @@ -350,8 +349,14 @@ fn replay_constraint_failure_shows_real_names_not_placeholders() { // INSERT command — the **real offending value** is shown too (it used // to degrade to the neutral "that value" because `SqlInsert` discarded // its literals). - assert!(error.contains("T.email"), "names the real table.column; got: {error}"); - assert!(error.contains("a@b.com"), "shows the real offending value; got: {error}"); + assert!( + error.contains("T.email"), + "names the real table.column; got: {error}" + ); + assert!( + error.contains("a@b.com"), + "shows the real offending value; got: {error}" + ); } #[test] @@ -359,9 +364,8 @@ fn replay_missing_file_fails_with_line_number_zero() { let data = tempdir(); let (project, db) = open_project_db(data.path()); - let events = rt().block_on(async { - run_replay(&db, project.path(), "no-such-file.commands").await - }); + let events = + rt().block_on(async { run_replay(&db, project.path(), "no-such-file.commands").await }); let failed = assert_failed_at(&events, 0); let AppEvent::ReplayFailed { error, .. } = failed else { unreachable!() @@ -387,9 +391,7 @@ fn replay_aborts_on_first_parse_failure_and_reports_line() { insert into T (1, 'should not happen')\n", ); - let events = rt().block_on(async { - run_replay(&db, project.path(), "bad.commands").await - }); + let events = rt().block_on(async { run_replay(&db, project.path(), "bad.commands").await }); let failed = assert_failed_at(&events, 3); let AppEvent::ReplayFailed { error, command, .. } = failed else { unreachable!() @@ -452,9 +454,7 @@ fn replay_rejects_wrong_type_value_in_a_hand_built_script() { insert into T values (1, 'not a number')\n", ); - let events = rt().block_on(async { - run_replay(&db, project.path(), "typed.commands").await - }); + let events = rt().block_on(async { run_replay(&db, project.path(), "typed.commands").await }); let failed = assert_failed_at(&events, 3); let AppEvent::ReplayFailed { error, .. } = failed else { unreachable!() @@ -489,9 +489,7 @@ fn replay_aborts_on_first_runtime_failure_and_reports_line() { insert into T (1)\n", ); - let events = rt().block_on(async { - run_replay(&db, project.path(), "bad.commands").await - }); + let events = rt().block_on(async { run_replay(&db, project.path(), "bad.commands").await }); let _ = assert_failed_at(&events, 2); } @@ -504,23 +502,29 @@ fn replay_skips_nested_replay_with_a_warning() { // because the nested file's commands are not reconstructed. let data = tempdir(); let (project, db) = open_project_db(data.path()); - write_script(project.path(), "inner.commands", "create table T with pk id(int)\n"); + write_script( + project.path(), + "inner.commands", + "create table T with pk id(int)\n", + ); write_script( project.path(), "outer.commands", "create table U with pk id(int)\nreplay inner.commands\n", ); - let events = rt().block_on(async { - run_replay(&db, project.path(), "outer.commands").await - }); + let events = rt().block_on(async { run_replay(&db, project.path(), "outer.commands").await }); // The outer `create table U` ran; the nested `replay` was // skipped (count 1), with a warning. match events.last().expect("event") { - AppEvent::ReplayCompleted { count, warnings, .. } => { + AppEvent::ReplayCompleted { + count, warnings, .. + } => { assert_eq!(*count, 1, "only the outer create ran; events: {events:?}"); assert!( - warnings.iter().any(|w| w.contains("nested") && w.contains("replay inner.commands")), + warnings + .iter() + .any(|w| w.contains("nested") && w.contains("replay inner.commands")), "expected a nested-replay skip warning; got {warnings:?}", ); } @@ -528,7 +532,10 @@ fn replay_skips_nested_replay_with_a_warning() { } // 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).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)" + ); } #[test] @@ -546,20 +553,22 @@ fn replay_history_log_records_subcommands_only() { "create table T with pk id(int)\nadd column T: name (text)\n", ); - let events = rt().block_on(async { - run_replay(&db, project.path(), "seed.commands").await - }); + let events = rt().block_on(async { run_replay(&db, project.path(), "seed.commands").await }); assert_completed(&events, 2); - let history = fs::read_to_string(project.path().join("history.log")) - .expect("history.log exists"); + let history = + fs::read_to_string(project.path().join("history.log")).expect("history.log exists"); // Per-command entries landed. assert!( - history.lines().any(|l| l.contains("create table T with pk id(int)")), + history + .lines() + .any(|l| l.contains("create table T with pk id(int)")), "history.log missing create line:\n{history}" ); assert!( - history.lines().any(|l| l.contains("add column T: name (text)")), + history + .lines() + .any(|l| l.contains("add column T: name (text)")), "history.log missing add column line:\n{history}" ); // The replay invocation itself did NOT land — that's diff --git a/tests/it/seed.rs b/tests/it/seed.rs index ed389b1..7e2e41c 100644 --- a/tests/it/seed.rs +++ b/tests/it/seed.rs @@ -18,8 +18,7 @@ fn rt() -> tokio::runtime::Runtime { fn open_project_db() -> (project::Project, Database, tempfile::TempDir) { let dir = tempfile::tempdir().expect("create tempdir"); - let project = - project::open_or_create(None, Some(dir.path())).expect("open or create project"); + let project = project::open_or_create(None, Some(dir.path())).expect("open or create project"); let persistence = Persistence::new(project.path().to_path_buf()); let db = Database::open_with_persistence(project.db_path(), persistence) .expect("open db with persistence"); @@ -76,7 +75,10 @@ fn seed_parses_with_and_without_count() { match parse_command("seed People").expect("`seed People` parses") { Command::Seed { table, count, .. } => { assert_eq!(table, "People"); - assert_eq!(count, None, "omitted count is None (executor defaults to 20)"); + assert_eq!( + count, None, + "omitted count is None (executor defaults to 20)" + ); } other => panic!("expected Command::Seed, got {other:?}"), } @@ -134,7 +136,10 @@ fn seed_set_fixed_value_override_parses() { let (_t, ov) = seed_overrides("seed users 5 set status = 'active'"); assert_eq!(ov.len(), 1); assert_eq!(ov[0].column, "status"); - assert_eq!(ov[0].kind, SeedOverrideKind::Fixed(Value::Text("active".into()))); + assert_eq!( + ov[0].kind, + SeedOverrideKind::Fixed(Value::Text("active".into())) + ); } #[test] @@ -177,8 +182,7 @@ fn seed_set_numeric_range_override_parses() { #[test] fn seed_set_date_range_override_parses_with_quoted_dates() { // ADR-0048 D2 amendment: dates in the range form are quoted strings. - let (_t, ov) = - seed_overrides("seed users set signup between '2023-01-01' and '2024-12-31'"); + let (_t, ov) = seed_overrides("seed users set signup between '2023-01-01' and '2024-12-31'"); assert_eq!( ov[0].kind, SeedOverrideKind::Range { @@ -207,7 +211,9 @@ fn seed_count_is_not_confused_by_a_range_value() { // No positional count, but `between 18 and 80` carries NumberLits — // they must not be read as the count (bounded to before `set`). match parse_command("seed users set age between 18 and 80").expect("parses") { - Command::Seed { count, overrides, .. } => { + Command::Seed { + count, overrides, .. + } => { assert_eq!(count, None, "the count is None, not 18"); assert_eq!(overrides.len(), 1); } @@ -267,7 +273,14 @@ fn seed_populates_a_table_and_persists_rows() { create_people(&db, &rt); let result = rt - .block_on(db.seed("People".into(), None, Some(7), Vec::new(), Some(42), Some("seed People 7".into()))) + .block_on(db.seed( + "People".into(), + None, + Some(7), + Vec::new(), + Some(42), + Some("seed People 7".into()), + )) .expect("seed succeeds"); assert_eq!(result.produced, 7); @@ -278,22 +291,34 @@ fn seed_populates_a_table_and_persists_rows() { "CSV should hold 7 generated rows:\n{csv}" ); // The generated `email` column produces address-shaped values. - assert!(csv.contains('@'), "seeded emails should appear in the CSV:\n{csv}"); + assert!( + csv.contains('@'), + "seeded emails should appear in the CSV:\n{csv}" + ); } /// Parse a seeded table's CSV into per-column value lists (simple /// comma-split — the values under test carry no commas/quotes). fn csv_columns(csv: &str) -> (Vec, Vec>) { let mut lines = csv.lines().filter(|l| !l.trim().is_empty()); - let header: Vec = lines.next().unwrap().split(',').map(str::to_string).collect(); - let rows: Vec> = - lines.map(|l| l.split(',').map(str::to_string).collect()).collect(); + let header: Vec = lines + .next() + .unwrap() + .split(',') + .map(str::to_string) + .collect(); + let rows: Vec> = lines + .map(|l| l.split(',').map(str::to_string).collect()) + .collect(); (header, rows) } fn column_values(csv: &str, col: &str) -> Vec { let (header, rows) = csv_columns(csv); - let idx = header.iter().position(|h| h == col).expect("column present"); + let idx = header + .iter() + .position(|h| h == col) + .expect("column present"); rows.iter().map(|r| r[idx].clone()).collect() } @@ -321,20 +346,36 @@ fn seed_year_and_choice_set_heuristics() { )) .expect("create Records"); - rt.block_on(db.seed("Records".into(), None, Some(30), Vec::new(), Some(99), Some("seed Records 30".into()))) - .expect("seed succeeds"); + rt.block_on(db.seed( + "Records".into(), + None, + Some(30), + Vec::new(), + Some(99), + Some("seed Records 30".into()), + )) + .expect("seed succeeds"); let csv = read_csv(&project, "Records").expect("Records CSV exists"); for y in column_values(&csv, "birth_year") { let n: i32 = y.parse().expect("birth_year is an int"); - assert!((1945..=2007).contains(&n), "birth_year {n} must be a plausible birth year"); + assert!( + (1945..=2007).contains(&n), + "birth_year {n} must be a plausible birth year" + ); } for y in column_values(&csv, "published") { let n: i32 = y.parse().expect("published is an int"); - assert!((1950..=2025).contains(&n), "published {n} must be a plausible recent year"); + assert!( + (1950..=2025).contains(&n), + "published {n} must be a plausible recent year" + ); } for p in column_values(&csv, "priority") { - assert!(["low", "medium", "high"].contains(&p.as_str()), "priority `{p}` must be low/medium/high"); + assert!( + ["low", "medium", "high"].contains(&p.as_str()), + "priority `{p}` must be low/medium/high" + ); } for s in column_values(&csv, "severity") { assert!( @@ -405,7 +446,14 @@ fn seed_count_defaults_to_twenty() { create_people(&db, &rt); let result = rt - .block_on(db.seed("People".into(), None, None, Vec::new(), Some(1), Some("seed People".into()))) + .block_on(db.seed( + "People".into(), + None, + None, + Vec::new(), + Some(1), + Some("seed People".into()), + )) .expect("seed succeeds"); assert_eq!(result.produced, 20, "omitted count defaults to 20"); let csv = read_csv(&project, "People").expect("People CSV exists"); @@ -420,10 +468,24 @@ fn seed_is_reproducible_with_a_fixed_seed() { create_people(&db1, &rt); create_people(&db2, &rt); - rt.block_on(db1.seed("People".into(), None, Some(4), Vec::new(), Some(123), Some("seed People 4".into()))) - .expect("seed run 1"); - rt.block_on(db2.seed("People".into(), None, Some(4), Vec::new(), Some(123), Some("seed People 4".into()))) - .expect("seed run 2"); + rt.block_on(db1.seed( + "People".into(), + None, + Some(4), + Vec::new(), + Some(123), + Some("seed People 4".into()), + )) + .expect("seed run 1"); + rt.block_on(db2.seed( + "People".into(), + None, + Some(4), + Vec::new(), + Some(123), + Some("seed People 4".into()), + )) + .expect("seed run 2"); let csv1 = read_csv(&p1, "People").expect("csv 1"); let csv2 = read_csv(&p2, "People").expect("csv 2"); @@ -493,10 +555,24 @@ fn seed_fills_foreign_keys_from_existing_parents() { create_users_and_orders(&db, &rt, true); // 5 parents → serial ids 1..=5. - rt.block_on(db.seed("Users".into(), None, Some(5), Vec::new(), Some(1), Some("seed Users 5".into()))) - .expect("seed Users"); + rt.block_on(db.seed( + "Users".into(), + None, + Some(5), + Vec::new(), + Some(1), + Some("seed Users 5".into()), + )) + .expect("seed Users"); let res = rt - .block_on(db.seed("Orders".into(), None, Some(10), Vec::new(), Some(2), Some("seed Orders 10".into()))) + .block_on(db.seed( + "Orders".into(), + None, + Some(10), + Vec::new(), + Some(2), + Some("seed Orders 10".into()), + )) .expect("seed Orders"); assert_eq!(res.produced, 10, "every child row must insert (valid FK)"); @@ -520,10 +596,20 @@ fn seed_refuses_when_a_parent_table_is_empty() { // Users is empty — no valid FK can be fabricated. let err = rt - .block_on(db.seed("Orders".into(), None, Some(3), Vec::new(), Some(1), Some("seed Orders 3".into()))) + .block_on(db.seed( + "Orders".into(), + None, + Some(3), + Vec::new(), + Some(1), + Some("seed Orders 3".into()), + )) .expect_err("seed must refuse an empty parent"); let msg = err.to_string(); - assert!(msg.contains("Users"), "error should name the empty parent: {msg}"); + assert!( + msg.contains("Users"), + "error should name the empty parent: {msg}" + ); let lower = msg.to_lowercase(); assert!( lower.contains("no rows") || lower.contains("first"), @@ -546,7 +632,14 @@ fn seed_refuses_a_not_null_blob_column() { .expect("create Files"); let err = rt - .block_on(db.seed("Files".into(), None, Some(2), Vec::new(), Some(1), Some("seed Files 2".into()))) + .block_on(db.seed( + "Files".into(), + None, + Some(2), + Vec::new(), + Some(1), + Some("seed Files 2".into()), + )) .expect_err("seed must refuse a NOT NULL blob"); let msg = err.to_string(); assert!( @@ -573,7 +666,14 @@ fn seed_omits_a_nullable_blob_column() { .expect("create Files"); let res = rt - .block_on(db.seed("Files".into(), None, Some(3), Vec::new(), Some(1), Some("seed Files 3".into()))) + .block_on(db.seed( + "Files".into(), + None, + Some(3), + Vec::new(), + Some(1), + Some("seed Files 3".into()), + )) .expect("seed succeeds despite the nullable blob"); assert_eq!(res.produced, 3); let csv = read_csv(&project, "Files").expect("Files CSV"); @@ -607,14 +707,25 @@ fn seed_keeps_unique_columns_distinct() { .expect("create Tags"); let res = rt - .block_on(db.seed("Tags".into(), None, Some(8), Vec::new(), Some(3), Some("seed Tags 8".into()))) + .block_on(db.seed( + "Tags".into(), + None, + Some(8), + Vec::new(), + Some(3), + Some("seed Tags 8".into()), + )) .expect("seed"); assert_eq!(res.produced, 8); let csv = read_csv(&project, "Tags").expect("Tags CSV"); let labels = nth_column_values(&csv, 1); let distinct: std::collections::HashSet<&String> = labels.iter().collect(); - assert_eq!(distinct.len(), labels.len(), "UNIQUE column has duplicates:\n{csv}"); + assert_eq!( + distinct.len(), + labels.len(), + "UNIQUE column has duplicates:\n{csv}" + ); } #[test] @@ -636,7 +747,14 @@ fn seed_sequences_identifier_int_columns() { .expect("create Items"); let res = rt - .block_on(db.seed("Items".into(), None, Some(5), Vec::new(), Some(1), Some("seed Items 5".into()))) + .block_on(db.seed( + "Items".into(), + None, + Some(5), + Vec::new(), + Some(1), + Some("seed Items 5".into()), + )) .expect("seed"); assert_eq!(res.produced, 5); @@ -646,7 +764,11 @@ fn seed_sequences_identifier_int_columns() { .map(|s| s.parse().expect("code is an int")) .collect(); let distinct: std::collections::HashSet = codes.iter().copied().collect(); - assert_eq!(distinct.len(), 5, "identifier ints must be unique: {codes:?}"); + assert_eq!( + distinct.len(), + 5, + "identifier ints must be unique: {codes:?}" + ); } #[test] @@ -667,14 +789,24 @@ fn seed_junction_produces_distinct_combinations_and_caps() { ) .await .expect("create parent"); - db.seed(t.into(), None, Some(2), Vec::new(), Some(1), Some(format!("seed {t} 2"))) - .await - .expect("seed parent"); + db.seed( + t.into(), + None, + Some(2), + Vec::new(), + Some(1), + Some(format!("seed {t} 2")), + ) + .await + .expect("seed parent"); } // Junction with a compound PK over its two FK columns. db.create_table( "J".to_string(), - vec![ColumnSpec::new("a", Type::Int), ColumnSpec::new("b", Type::Int)], + vec![ + ColumnSpec::new("a", Type::Int), + ColumnSpec::new("b", Type::Int), + ], vec!["a".to_string(), "b".to_string()], None, ) @@ -709,11 +841,21 @@ fn seed_junction_produces_distinct_combinations_and_caps() { // Requesting 10 caps at the 4 available distinct combinations. let res = db - .seed("J".into(), None, Some(10), Vec::new(), Some(7), Some("seed J 10".into())) + .seed( + "J".into(), + None, + Some(10), + Vec::new(), + Some(7), + Some("seed J 10".into()), + ) .await .expect("seed J"); assert_eq!(res.produced, 4, "junction caps at available combos"); - assert_eq!(res.requested, 10, "the requested count is reported for the cap note"); + assert_eq!( + res.requested, 10, + "the requested count is reported for the cap note" + ); }); let csv = read_csv(&project, "J").expect("J CSV"); @@ -724,7 +866,11 @@ fn seed_junction_produces_distinct_combinations_and_caps() { .map(str::to_string) .collect(); let distinct: std::collections::HashSet<&String> = pairs.iter().collect(); - assert_eq!(distinct.len(), pairs.len(), "junction rows must be distinct:\n{csv}"); + assert_eq!( + distinct.len(), + pairs.len(), + "junction rows must be distinct:\n{csv}" + ); } #[test] @@ -743,9 +889,19 @@ fn seed_draws_enum_values_from_an_in_check() { // Every generated status must satisfy the CHECK, so all rows insert. let res = rt - .block_on(db.seed("Tickets".into(), None, Some(12), Vec::new(), Some(2), Some("seed Tickets 12".into()))) + .block_on(db.seed( + "Tickets".into(), + None, + Some(12), + Vec::new(), + Some(2), + Some("seed Tickets 12".into()), + )) .expect("seed"); - assert_eq!(res.produced, 12, "all rows insert — values satisfy the CHECK"); + assert_eq!( + res.produced, 12, + "all rows insert — values satisfy the CHECK" + ); let csv = read_csv(&project, "Tickets").expect("Tickets CSV"); for v in nth_column_values(&csv, 1) { @@ -780,7 +936,14 @@ fn seed_advises_on_enum_ish_columns() { .expect("create Tasks"); let res = rt - .block_on(db.seed("Tasks".into(), None, Some(3), Vec::new(), Some(1), Some("seed Tasks 3".into()))) + .block_on(db.seed( + "Tasks".into(), + None, + Some(3), + Vec::new(), + Some(1), + Some("seed Tasks 3".into()), + )) .expect("seed"); assert!( res.advisory_columns.contains(&"status".to_string()), @@ -795,7 +958,14 @@ fn seed_refuses_an_excessive_count() { let rt = rt(); create_people(&db, &rt); let err = rt - .block_on(db.seed("People".into(), None, Some(1_000_000), Vec::new(), Some(1), Some("seed People 1000000".into()))) + .block_on(db.seed( + "People".into(), + None, + Some(1_000_000), + Vec::new(), + Some(1), + Some("seed People 1000000".into()), + )) .expect_err("an excessive count must be refused"); assert!( err.to_string().to_lowercase().contains("maximum"), @@ -810,7 +980,14 @@ fn seed_preview_is_capped_but_count_is_full() { create_people(&db, &rt); let res = rt - .block_on(db.seed("People".into(), None, Some(25), Vec::new(), Some(1), Some("seed People 25".into()))) + .block_on(db.seed( + "People".into(), + None, + Some(25), + Vec::new(), + Some(1), + Some("seed People 25".into()), + )) .expect("seed"); assert_eq!(res.produced, 25, "the full count is produced"); assert_eq!(res.data.rows.len(), 20, "the preview is capped at 20 rows"); @@ -860,14 +1037,24 @@ fn seed_is_one_undo_step() { .expect("open db with undo"); let rt = rt(); create_people(&db, &rt); - rt.block_on(db.seed("People".into(), None, Some(6), Vec::new(), Some(1), Some("seed People 6".into()))) - .expect("seed"); + rt.block_on(db.seed( + "People".into(), + None, + Some(6), + Vec::new(), + Some(1), + Some("seed People 6".into()), + )) + .expect("seed"); assert_eq!(data_row_count(&read_csv(&project, "People").unwrap()), 6); // One undo removes the whole seed batch (ADR-0048 D15). rt.block_on(db.undo()).unwrap().expect("undo applied"); let rows = read_csv(&project, "People").map_or(0, |c| data_row_count(&c)); - assert_eq!(rows, 0, "one undo must remove every seeded row in a single step"); + assert_eq!( + rows, 0, + "one undo must remove every seeded row in a single step" + ); } #[test] @@ -882,10 +1069,17 @@ fn seed_column_fill_is_one_undo_step() { create_members(&db, &rt); run_seed(&db, &rt, "seed Members 5 --seed 1").expect("seed"); // Fill `status` across all 5 rows with a constant, then undo once. - run_seed(&db, &rt, "seed Members.status set status = 'flagged' --seed 2") - .expect("column-fill"); + run_seed( + &db, + &rt, + "seed Members.status set status = 'flagged' --seed 2", + ) + .expect("column-fill"); let before = named_column_values(&read_csv(&project, "Members").unwrap(), "status"); - assert!(before.iter().all(|s| s == "flagged"), "all rows filled: {before:?}"); + assert!( + before.iter().all(|s| s == "flagged"), + "all rows filled: {before:?}" + ); rt.block_on(db.undo()).unwrap().expect("undo applied"); let after = named_column_values(&read_csv(&project, "Members").unwrap(), "status"); @@ -893,7 +1087,11 @@ fn seed_column_fill_is_one_undo_step() { after.iter().all(|s| s != "flagged"), "one undo reverts the whole column-fill in a single step: {after:?}" ); - assert_eq!(after.len(), 5, "undo restores the original rows, not removes them"); + assert_eq!( + after.len(), + 5, + "undo restores the original rows, not removes them" + ); } #[test] @@ -930,10 +1128,23 @@ fn seed_rolls_back_atomically_on_a_constraint_failure() { )) .expect("create Bad"); - let res = rt.block_on(db.seed("Bad".into(), None, Some(5), Vec::new(), Some(1), Some("seed Bad 5".into()))); - assert!(res.is_err(), "seed must fail when generated rows violate the CHECK"); + let res = rt.block_on(db.seed( + "Bad".into(), + None, + Some(5), + Vec::new(), + Some(1), + Some("seed Bad 5".into()), + )); + assert!( + res.is_err(), + "seed must fail when generated rows violate the CHECK" + ); let rows = read_csv(&project, "Bad").map_or(0, |c| data_row_count(&c)); - assert_eq!(rows, 0, "a failed seed must leave the table unchanged (atomic)"); + assert_eq!( + rows, 0, + "a failed seed must leave the table unchanged (atomic)" + ); } #[test] @@ -942,7 +1153,14 @@ fn seed_zero_is_a_no_op() { let rt = rt(); create_people(&db, &rt); let res = rt - .block_on(db.seed("People".into(), None, Some(0), Vec::new(), Some(1), Some("seed People 0".into()))) + .block_on(db.seed( + "People".into(), + None, + Some(0), + Vec::new(), + Some(1), + Some("seed People 0".into()), + )) .expect("seed 0 succeeds"); assert_eq!(res.produced, 0); let rows = read_csv(&project, "People").map_or(0, |c| data_row_count(&c)); @@ -967,7 +1185,14 @@ fn seed_advises_on_a_complex_check_column() { .expect("create Widgets"); let res = rt - .block_on(db.seed("Widgets".into(), None, Some(3), Vec::new(), Some(1), Some("seed Widgets 3".into()))) + .block_on(db.seed( + "Widgets".into(), + None, + Some(3), + Vec::new(), + Some(1), + Some("seed Widgets 3".into()), + )) .expect("seed"); assert!( res.advisory_columns.contains(&"label".to_string()), @@ -981,10 +1206,24 @@ fn seed_foreign_keys_are_reproducible_with_a_fixed_seed() { let rt = rt(); let seed_one = |db: &Database| { create_users_and_orders(db, &rt, true); - rt.block_on(db.seed("Users".into(), None, Some(4), Vec::new(), Some(1), Some("seed Users 4".into()))) - .expect("seed users"); - rt.block_on(db.seed("Orders".into(), None, Some(8), Vec::new(), Some(99), Some("seed Orders 8".into()))) - .expect("seed orders"); + rt.block_on(db.seed( + "Users".into(), + None, + Some(4), + Vec::new(), + Some(1), + Some("seed Users 4".into()), + )) + .expect("seed users"); + rt.block_on(db.seed( + "Orders".into(), + None, + Some(8), + Vec::new(), + Some(99), + Some("seed Orders 8".into()), + )) + .expect("seed orders"); }; let (p1, db1, _d1) = open_project_db(); let (p2, db2, _d2) = open_project_db(); @@ -1013,8 +1252,15 @@ fn seed_shortid_columns_are_reproducible_with_a_fixed_seed() { None, )) .expect("create Contacts"); - rt.block_on(db.seed("Contacts".into(), None, Some(5), Vec::new(), Some(42), Some("seed Contacts 5".into()))) - .expect("seed"); + rt.block_on(db.seed( + "Contacts".into(), + None, + Some(5), + Vec::new(), + Some(42), + Some("seed Contacts 5".into()), + )) + .expect("seed"); }; let (p1, db1, _d1) = open_project_db(); let (p2, db2, _d2) = open_project_db(); @@ -1023,13 +1269,20 @@ fn seed_shortid_columns_are_reproducible_with_a_fixed_seed() { let csv1 = read_csv(&p1, "Contacts").unwrap(); let csv2 = read_csv(&p2, "Contacts").unwrap(); - assert_eq!(csv1, csv2, "shortid values must reproduce under a fixed --seed"); + assert_eq!( + csv1, csv2, + "shortid values must reproduce under a fixed --seed" + ); // The shortid PK is populated with distinct 10-char base58 ids. let codes = nth_column_values(&csv1, 0); assert_eq!(codes.len(), 5); let distinct: std::collections::HashSet<&String> = codes.iter().collect(); - assert_eq!(distinct.len(), 5, "shortid PK values must be distinct: {codes:?}"); + assert_eq!( + distinct.len(), + 5, + "shortid PK values must be distinct: {codes:?}" + ); for code in &codes { assert_eq!(code.len(), 10, "shortid should be 10 chars: {code}"); } @@ -1105,7 +1358,10 @@ fn seed_set_fixed_value_fills_every_row() { let csv = read_csv(&project, "Members").unwrap(); let statuses = named_column_values(&csv, "status"); assert_eq!(statuses.len(), 6); - assert!(statuses.iter().all(|s| s == "active"), "every status pinned: {statuses:?}"); + assert!( + statuses.iter().all(|s| s == "active"), + "every status pinned: {statuses:?}" + ); } #[test] @@ -1113,7 +1369,12 @@ fn seed_set_pick_list_draws_only_from_the_list() { let (project, db, _d) = open_project_db(); let rt = rt(); create_members(&db, &rt); - run_seed(&db, &rt, "seed Members 20 set role in ('admin', 'user') --seed 2").expect("seed"); + run_seed( + &db, + &rt, + "seed Members 20 set role in ('admin', 'user') --seed 2", + ) + .expect("seed"); let csv = read_csv(&project, "Members").unwrap(); let roles = named_column_values(&csv, "role"); assert!( @@ -1131,7 +1392,10 @@ fn seed_set_as_generator_forces_the_shape() { run_seed(&db, &rt, "seed Members 5 set name as email --seed 3").expect("seed"); let csv = read_csv(&project, "Members").unwrap(); let names = named_column_values(&csv, "name"); - assert!(names.iter().all(|n| n.contains('@')), "name forced to email shape: {names:?}"); + assert!( + names.iter().all(|n| n.contains('@')), + "name forced to email shape: {names:?}" + ); } #[test] @@ -1139,7 +1403,12 @@ fn seed_set_numeric_range_stays_within_bounds() { let (project, db, _d) = open_project_db(); let rt = rt(); create_members(&db, &rt); - run_seed(&db, &rt, "seed Members 30 set age between 30 and 40 --seed 4").expect("seed"); + run_seed( + &db, + &rt, + "seed Members 30 set age between 30 and 40 --seed 4", + ) + .expect("seed"); let csv = read_csv(&project, "Members").unwrap(); for a in named_column_values(&csv, "age") { let n: i64 = a.parse().unwrap_or_else(|_| panic!("age `{a}` not an int")); @@ -1190,7 +1459,10 @@ fn seed_incompatible_range_is_a_friendly_error() { // A numeric range on a text column (`name`) is rejected. let err = run_seed(&db, &rt, "seed Members 3 set name between 1 and 10").unwrap_err(); let msg = format!("{err}"); - assert!(msg.contains("between"), "range error should mention `between`: {msg}"); + assert!( + msg.contains("between"), + "range error should mention `between`: {msg}" + ); } #[test] @@ -1221,8 +1493,12 @@ fn seed_column_fill_updates_existing_rows_without_adding() { let before = data_row_count(&read_csv(&project, "Members").unwrap()); assert_eq!(before, 5); - let res = run_seed(&db, &rt, "seed Members.status set status in ('x', 'y') --seed 2") - .expect("column-fill"); + let res = run_seed( + &db, + &rt, + "seed Members.status set status in ('x', 'y') --seed 2", + ) + .expect("column-fill"); assert_eq!(res.produced, 5, "column-fill touches the 5 existing rows"); let csv = read_csv(&project, "Members").unwrap(); assert_eq!(data_row_count(&csv), 5, "no new rows added"); @@ -1240,7 +1516,10 @@ fn seed_column_fill_refuses_a_pk_target() { create_members(&db, &rt); run_seed(&db, &rt, "seed Members 3 --seed 1").expect("seed"); let err = run_seed(&db, &rt, "seed Members.id").unwrap_err(); - assert!(format!("{err}").contains("primary key"), "PK target refused: {err}"); + assert!( + format!("{err}").contains("primary key"), + "PK target refused: {err}" + ); } #[test] @@ -1282,7 +1561,10 @@ fn seed_column_fill_rejects_a_row_count() { Some("seed Members.status 5".into()), )) .unwrap_err(); - assert!(format!("{err}").contains("no row count"), "count refused: {err}"); + assert!( + format!("{err}").contains("no row count"), + "count refused: {err}" + ); } #[test] @@ -1298,7 +1580,11 @@ fn seed_column_fill_fk_target_samples_the_parent() { assert_eq!(res.produced, 8); let csv = read_csv(&project, "Orders").unwrap(); let user_ids = named_column_values(&csv, "user_id"); - assert!(user_ids.iter().all(|v| (1..=4).contains(&v.parse::().unwrap()))); + assert!( + user_ids + .iter() + .all(|v| (1..=4).contains(&v.parse::().unwrap())) + ); } #[test] @@ -1310,14 +1596,11 @@ fn seed_fixed_override_on_unique_column_is_a_friendly_error() { let rt = rt(); rt.block_on(db.create_table( "U".to_string(), - vec![ - ColumnSpec::new("id", Type::Serial), - { - let mut c = ColumnSpec::new("email", Type::Text); - c.unique = true; - c - }, - ], + vec![ColumnSpec::new("id", Type::Serial), { + let mut c = ColumnSpec::new("email", Type::Text); + c.unique = true; + c + }], vec!["id".to_string()], None, )) @@ -1330,7 +1613,10 @@ fn seed_fixed_override_on_unique_column_is_a_friendly_error() { ); // A short pick-list (< count) is likewise refused... let err2 = run_seed(&db, &rt, "seed U 5 set email in ('a@b.c', 'd@e.f')").unwrap_err(); - assert!(format!("{err2}").contains("distinct"), "short list refused: {err2}"); + assert!( + format!("{err2}").contains("distinct"), + "short list refused: {err2}" + ); // ...but a pick-list with enough distinct values succeeds. let ok = run_seed( &db, @@ -1354,14 +1640,11 @@ fn seed_column_fill_fixed_on_unique_column_is_a_friendly_error() { let rt = rt(); rt.block_on(db.create_table( "U".to_string(), - vec![ - ColumnSpec::new("id", Type::Serial), - { - let mut c = ColumnSpec::new("email", Type::Text); - c.unique = true; - c - }, - ], + vec![ColumnSpec::new("id", Type::Serial), { + let mut c = ColumnSpec::new("email", Type::Text); + c.unique = true; + c + }], vec!["id".to_string()], None, )) diff --git a/tests/it/show_list.rs b/tests/it/show_list.rs index a5f9c69..82c8980 100644 --- a/tests/it/show_list.rs +++ b/tests/it/show_list.rs @@ -17,7 +17,7 @@ use rdbms_playground::action::Action; use rdbms_playground::app::App; use rdbms_playground::db::Database; use rdbms_playground::dsl::{ - parse_command, ColumnSpec, Command, ReferentialAction, ShowListKind, Type, + ColumnSpec, Command, ReferentialAction, ShowListKind, Type, parse_command, }; use rdbms_playground::event::AppEvent; use rdbms_playground::mode::Mode; @@ -108,8 +108,7 @@ fn rt() -> tokio::runtime::Runtime { fn open_project_db() -> (project::Project, Database, tempfile::TempDir) { let dir = tempfile::tempdir().expect("create tempdir"); - let project = - project::open_or_create(None, Some(dir.path())).expect("open or create project"); + let project = project::open_or_create(None, Some(dir.path())).expect("open or create project"); let persistence = Persistence::new(project.path().to_path_buf()); let db = Database::open_with_persistence(project.db_path(), persistence) .expect("open db with persistence"); @@ -195,8 +194,7 @@ fn show_relationships_lists_name_endpoints_and_nondefault_action() { // Name, both endpoints, and the non-default ON DELETE CASCADE // (ON UPDATE NO ACTION is the default and is omitted). assert_eq!( - lines[1], - " orders_customer: Customers.id → Orders.customer_id on delete cascade", + lines[1], " orders_customer: Customers.id → Orders.customer_id on delete cascade", "relationship summary line: {lines:?}", ); } @@ -222,7 +220,8 @@ fn show_lists_report_empty_collections_with_friendly_lines() { let rt = rt(); // No schema seeded — every kind is empty. assert_eq!( - rt.block_on(db.show_list(ShowListKind::Tables, None)).unwrap(), + rt.block_on(db.show_list(ShowListKind::Tables, None)) + .unwrap(), vec!["No tables in this project yet.".to_string()], ); assert_eq!( @@ -231,7 +230,8 @@ fn show_lists_report_empty_collections_with_friendly_lines() { vec!["No relationships in this project yet.".to_string()], ); assert_eq!( - rt.block_on(db.show_list(ShowListKind::Indexes, None)).unwrap(), + rt.block_on(db.show_list(ShowListKind::Indexes, None)) + .unwrap(), vec!["No indexes in this project yet.".to_string()], ); } @@ -246,7 +246,10 @@ fn show_one_relationship_renders_detail_block() { let rt = rt(); rt.block_on(seed_schema(&db)); let lines = rt - .block_on(db.show_list(ShowListKind::Relationships, Some("orders_customer".to_string()))) + .block_on(db.show_list( + ShowListKind::Relationships, + Some("orders_customer".to_string()), + )) .expect("show relationship"); assert_eq!(lines[0], "Relationship `orders_customer`:"); assert_eq!(lines[1], " Customers.id → Orders.customer_id"); @@ -262,7 +265,10 @@ fn show_one_index_renders_detail_block() { let rt = rt(); rt.block_on(seed_schema(&db)); let lines = rt - .block_on(db.show_list(ShowListKind::Indexes, Some("idx_orders_customer".to_string()))) + .block_on(db.show_list( + ShowListKind::Indexes, + Some("idx_orders_customer".to_string()), + )) .expect("show index"); assert_eq!(lines[0], "Index `idx_orders_customer` on Orders:"); assert!( @@ -329,7 +335,10 @@ fn app_show_tables_dispatches_show_list_command() { } ) }); - assert!(dispatched, "submit dispatches ShowList(Tables): {actions:?}"); + assert!( + dispatched, + "submit dispatches ShowList(Tables): {actions:?}" + ); } #[test] @@ -337,10 +346,11 @@ fn app_renders_show_list_lines_as_system_output() { // Feed the success event directly so the test stays // self-contained (the worker round-trip is covered above). let mut app = App::new(); - app.output.push_back(rdbms_playground::app::OutputLine::echo( - "show tables", - Mode::Simple, - )); + app.output + .push_back(rdbms_playground::app::OutputLine::echo( + "show tables", + Mode::Simple, + )); app.update(AppEvent::DslShowListSucceeded { command: Command::ShowList { kind: ShowListKind::Tables, @@ -403,10 +413,11 @@ fn app_renders_show_relationship_as_a_styled_diagram() { .expect("found"); let mut app = App::new(); - app.output.push_back(rdbms_playground::app::OutputLine::echo( - "show relationship orders_customer", - Mode::Simple, - )); + app.output + .push_back(rdbms_playground::app::OutputLine::echo( + "show relationship orders_customer", + Mode::Simple, + )); app.update(AppEvent::DslShowRelationshipSucceeded { command: Command::ShowList { kind: ShowListKind::Relationships, @@ -423,7 +434,10 @@ fn app_renders_show_relationship_as_a_styled_diagram() { // Both tables, box-drawing, the connector arrow, the actions line. assert!(text.contains("Orders"), "child box: {text}"); assert!(text.contains("Customers"), "parent box: {text}"); - assert!(text.contains('┌') && text.contains('│'), "box drawing: {text}"); + assert!( + text.contains('┌') && text.contains('│'), + "box drawing: {text}" + ); assert!(text.contains('▶'), "connector arrow: {text}"); assert!(text.contains("on delete cascade"), "actions: {text}"); // The diagram lines are styled (per-span runs), not plain system. @@ -436,10 +450,11 @@ fn app_renders_show_relationship_as_a_styled_diagram() { #[test] fn app_show_relationship_not_found_shows_friendly_line() { let mut app = App::new(); - app.output.push_back(rdbms_playground::app::OutputLine::echo( - "show relationship nope", - Mode::Simple, - )); + app.output + .push_back(rdbms_playground::app::OutputLine::echo( + "show relationship nope", + Mode::Simple, + )); app.update(AppEvent::DslShowRelationshipSucceeded { command: Command::ShowList { kind: ShowListKind::Relationships, @@ -466,10 +481,11 @@ fn app_show_table_renders_relationships_as_compact_diagrams() { .expect("describe Orders"); let mut app = App::new(); - app.output.push_back(rdbms_playground::app::OutputLine::echo( - "show table Orders", - Mode::Simple, - )); + app.output + .push_back(rdbms_playground::app::OutputLine::echo( + "show table Orders", + Mode::Simple, + )); app.update(AppEvent::DslSucceeded { command: Command::ShowTable { name: "Orders".to_string(), diff --git a/tests/it/sql_alter_table.rs b/tests/it/sql_alter_table.rs index 401b097..c262a34 100644 --- a/tests/it/sql_alter_table.rs +++ b/tests/it/sql_alter_table.rs @@ -30,8 +30,7 @@ fn rt() -> tokio::runtime::Runtime { fn open() -> (project::Project, Database, tempfile::TempDir) { let dir = tempfile::tempdir().expect("create tempdir"); - let project = - project::open_or_create(None, Some(dir.path())).expect("open or create project"); + let project = project::open_or_create(None, Some(dir.path())).expect("open or create project"); let db = Database::open_with_persistence( project.db_path(), Persistence::new(project.path().to_path_buf()), @@ -42,8 +41,7 @@ fn open() -> (project::Project, Database, tempfile::TempDir) { fn open_with_undo() -> (project::Project, Database, tempfile::TempDir) { let dir = tempfile::tempdir().expect("create tempdir"); - let project = - project::open_or_create(None, Some(dir.path())).expect("open or create project"); + let project = project::open_or_create(None, Some(dir.path())).expect("open or create project"); let db = Database::open_with_persistence_and_undo( project.db_path(), Persistence::new(project.path().to_path_buf()), @@ -159,7 +157,10 @@ fn e2e_alter_table_add_rename_drop_and_raw_default_check() { // Final schema: id, label (renamed from v), qty; `note` added then // dropped. let cols = column_names(&db, &r); - assert_eq!(cols, vec!["id".to_string(), "label".to_string(), "qty".to_string()]); + assert_eq!( + cols, + vec!["id".to_string(), "label".to_string(), "qty".to_string()] + ); // The DEFAULT backfilled the pre-existing row to qty = 0. let rows = r @@ -168,14 +169,21 @@ fn e2e_alter_table_add_rename_drop_and_raw_default_check() { .rows; assert_eq!(rows.len(), 1); // qty is the third column; the rebuild backfilled the default. - assert_eq!(rows[0][2].as_deref(), Some("0"), "DEFAULT 0 backfilled the existing row"); + assert_eq!( + rows[0][2].as_deref(), + Some("0"), + "DEFAULT 0 backfilled the existing row" + ); // The CHECK (qty >= 0) is enforced: a negative qty is refused. assert!( r.block_on(db.insert( "T".to_string(), Some(vec!["id".to_string(), "qty".to_string()]), - vec![Value::Number("2".to_string()), Value::Number("-1".to_string())], + vec![ + Value::Number("2".to_string()), + Value::Number("-1".to_string()) + ], Some("insert".to_string()), )) .is_err(), @@ -185,7 +193,10 @@ fn e2e_alter_table_add_rename_drop_and_raw_default_check() { r.block_on(db.insert( "T".to_string(), Some(vec!["id".to_string(), "qty".to_string()]), - vec![Value::Number("3".to_string()), Value::Number("7".to_string())], + vec![ + Value::Number("3".to_string()), + Value::Number("7".to_string()), + ], Some("insert".to_string()), )) .expect("qty = 7 satisfies the CHECK"); @@ -214,7 +225,10 @@ fn e2e_alter_add_column_survives_rebuild() { r.block_on(db.insert( "T".to_string(), Some(vec!["id".to_string(), "qty".to_string()]), - vec![Value::Number("1".to_string()), Value::Number("-5".to_string())], + vec![ + Value::Number("1".to_string()), + Value::Number("-5".to_string()) + ], Some("insert".to_string()), )) .is_err(), @@ -257,9 +271,17 @@ fn e2e_alter_column_type_clean_and_lossy_convert() { .rows; assert_eq!(rows.len(), 1); // v (col 1): lossy real→int performed → 3.7 stored as 3. - assert_eq!(rows[0][1].as_deref(), Some("3"), "lossy real→int performed (3.7→3)"); + assert_eq!( + rows[0][1].as_deref(), + Some("3"), + "lossy real→int performed (3.7→3)" + ); // w (col 2): clean int→text stringified → "42". - assert_eq!(rows[0][2].as_deref(), Some("42"), "clean int→text stringified"); + assert_eq!( + rows[0][2].as_deref(), + Some("42"), + "clean int→text stringified" + ); // The columns now carry the new user-facing types (round-tripped // through the metadata). @@ -290,12 +312,20 @@ fn e2e_alter_column_type_int_to_serial_is_allowed() { } other => panic!("expected ReplayCompleted, got {other:?} (events: {events:?})"), } - 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 .block_on(db.query_data("T".to_string(), None, None)) .expect("query") .rows; - assert_eq!(rows[0][1].as_deref(), Some("100"), "the existing value is preserved"); + assert_eq!( + rows[0][1].as_deref(), + Some("100"), + "the existing value is preserved" + ); } #[test] @@ -368,11 +398,19 @@ fn e2e_alter_column_type_survives_rebuild() { ) .expect("write script"); r.block_on(run_replay(&db, project.path(), "conv.commands")); - assert_eq!(col_type(&db, &r, "v"), Some(Type::Int), "converted before rebuild"); + assert_eq!( + col_type(&db, &r, "v"), + Some(Type::Int), + "converted before rebuild" + ); r.block_on(db.rebuild_from_text(project.path().to_path_buf(), Some("rebuild".to_string()))) .expect("rebuild"); - assert_eq!(col_type(&db, &r, "v"), Some(Type::Int), "the converted type survives rebuild"); + assert_eq!( + col_type(&db, &r, "v"), + Some(Type::Int), + "the converted type survives rebuild" + ); } #[test] @@ -393,14 +431,22 @@ fn e2e_alter_column_type_is_one_undo_step() { ) .expect("write script"); r.block_on(run_replay(&db, project.path(), "conv.commands")); - assert_eq!(col_type(&db, &r, "v"), Some(Type::Int), "the SQL ALTER COLUMN TYPE converted v"); + assert_eq!( + col_type(&db, &r, "v"), + Some(Type::Int), + "the SQL ALTER COLUMN TYPE converted v" + ); // A single undo reverts the whole conversion. assert!( r.block_on(db.undo()).expect("undo").is_some(), "the conversion was one undo step" ); - assert_eq!(col_type(&db, &r, "v"), Some(Type::Real), "one undo restored the pre-conversion type"); + assert_eq!( + col_type(&db, &r, "v"), + Some(Type::Real), + "one undo restored the pre-conversion type" + ); } // --- 4g: ADD/DROP constraint + ADD foreign key (ADR-0035 §4g) ----------- @@ -410,7 +456,10 @@ fn insert_t_qty_ok(db: &Database, r: &tokio::runtime::Runtime, id: i64, qty: i64 r.block_on(db.insert( "T".to_string(), Some(vec!["id".to_string(), "qty".to_string()]), - vec![Value::Number(id.to_string()), Value::Number(qty.to_string())], + vec![ + Value::Number(id.to_string()), + Value::Number(qty.to_string()), + ], Some("insert".to_string()), )) .is_ok() @@ -436,14 +485,23 @@ fn e2e_add_named_check_enforced_and_survives_rebuild_with_its_name() { "events: {events:?}" ); // Enforced: qty = -1 refused, qty = 5 accepted. - assert!(!insert_t_qty_ok(&db, &r, 1, -1), "the CHECK rejects qty = -1"); - assert!(insert_t_qty_ok(&db, &r, 2, 5), "qty = 5 satisfies the CHECK"); + assert!( + !insert_t_qty_ok(&db, &r, 1, -1), + "the CHECK rejects qty = -1" + ); + assert!( + insert_t_qty_ok(&db, &r, 2, 5), + "qty = 5 satisfies the CHECK" + ); // Rebuild from text, then DROP CONSTRAINT by name must still work → // the name survived the round-trip. r.block_on(db.rebuild_from_text(project.path().to_path_buf(), Some("rebuild".to_string()))) .expect("rebuild"); - assert!(!insert_t_qty_ok(&db, &r, 3, -2), "the CHECK is intact after rebuild"); + assert!( + !insert_t_qty_ok(&db, &r, 3, -2), + "the CHECK is intact after rebuild" + ); std::fs::write( project.path().join("drop.commands"), "alter table T drop constraint qty_positive\n", @@ -502,13 +560,19 @@ fn e2e_add_composite_unique_enforced_and_survives_rebuild() { )) .is_ok() }; - assert!(!dup_ok(2, 1, 2), "the composite UNIQUE rejects the duplicate (1, 2)"); + assert!( + !dup_ok(2, 1, 2), + "the composite UNIQUE rejects the duplicate (1, 2)" + ); assert!(dup_ok(3, 1, 3), "(1, 3) is distinct and accepted"); // Survives rebuild (the unique_constraints yaml path). r.block_on(db.rebuild_from_text(project.path().to_path_buf(), Some("rebuild".to_string()))) .expect("rebuild"); - assert!(!dup_ok(4, 1, 2), "the composite UNIQUE is intact after rebuild"); + assert!( + !dup_ok(4, 1, 2), + "the composite UNIQUE is intact after rebuild" + ); } #[test] @@ -559,7 +623,10 @@ fn e2e_drop_composite_unique_by_derived_name() { .is_ok() }; assert!(dup_ok(1, 1, 2), "first (1, 2) accepted"); - assert!(!dup_ok(2, 1, 2), "duplicate (1, 2) rejected while the UNIQUE stands"); + assert!( + !dup_ok(2, 1, 2), + "duplicate (1, 2) rejected while the UNIQUE stands" + ); // Drop the UNIQUE by its derived name through the existing DROP // CONSTRAINT grammar. @@ -572,7 +639,10 @@ fn e2e_drop_composite_unique_by_derived_name() { // The UNIQUE no longer enforces: the previously-rejected duplicate is // now accepted. - assert!(dup_ok(3, 1, 2), "duplicate (1, 2) accepted after the UNIQUE was dropped"); + assert!( + dup_ok(3, 1, 2), + "duplicate (1, 2) accepted after the UNIQUE was dropped" + ); // And it stays gone across a rebuild from text. r.block_on(db.rebuild_from_text(project.path().to_path_buf(), Some("rebuild".to_string()))) @@ -676,8 +746,14 @@ fn e2e_add_foreign_key_missing_child_column_refuses_without_dsl_flag() { let AppEvent::ReplayFailed { error, .. } = events.last().expect("an event") else { panic!("expected ReplayFailed; events: {events:?}"); }; - assert!(!error.contains("--create-fk"), "no DSL flag in the SQL refusal; got: {error}"); - assert!(error.contains("pid"), "names the missing column; got: {error}"); + assert!( + !error.contains("--create-fk"), + "no DSL flag in the SQL refusal; got: {error}" + ); + assert!( + error.contains("pid"), + "names the missing column; got: {error}" + ); assert!( error.to_lowercase().contains("add it first") || error.to_lowercase().contains("does not exist"), @@ -709,12 +785,21 @@ fn e2e_add_foreign_key_creates_an_enforced_relationship() { r.block_on(db.insert( "C".to_string(), Some(vec!["cid".to_string(), "pid".to_string()]), - vec![Value::Number(cid.to_string()), Value::Number(pid.to_string())], + vec![ + Value::Number(cid.to_string()), + Value::Number(pid.to_string()), + ], Some("insert".to_string()), )) }; - assert!(insert_c(10, 1).is_ok(), "a child referencing parent id=1 is accepted"); - assert!(insert_c(11, 999).is_err(), "a child referencing a missing parent is rejected"); + assert!( + insert_c(10, 1).is_ok(), + "a child referencing parent id=1 is accepted" + ); + assert!( + insert_c(11, 999).is_err(), + "a child referencing a missing parent is rejected" + ); } #[test] @@ -740,7 +825,10 @@ fn e2e_drop_constraint_removes_a_named_foreign_key() { r.block_on(db.insert( "C".to_string(), Some(vec!["cid".to_string(), "pid".to_string()]), - vec![Value::Number("1".to_string()), Value::Number("999".to_string())], + vec![ + Value::Number("1".to_string()), + Value::Number("999".to_string()) + ], Some("insert".to_string()), )) .is_ok(), @@ -798,7 +886,10 @@ fn e2e_add_constraint_is_one_undo_step() { "the ADD CONSTRAINT was one undo step" ); // After undo the CHECK is gone: qty = -1 is accepted. - assert!(insert_t_qty_ok(&db, &r, 3, -1), "one undo removed the CHECK"); + assert!( + insert_t_qty_ok(&db, &r, 3, -1), + "one undo removed the CHECK" + ); } #[test] @@ -819,7 +910,10 @@ fn e2e_named_check_metadata_survives_a_fresh_rebuild() { .expect("db"); r.block_on(db.sql_create_table( "T".to_string(), - vec![ColumnSpec::new("id", Type::Int), ColumnSpec::new("qty", Type::Int)], + vec![ + ColumnSpec::new("id", Type::Int), + ColumnSpec::new("qty", Type::Int), + ], vec!["id".to_string()], vec![], vec![], @@ -846,7 +940,8 @@ fn e2e_named_check_metadata_survives_a_fresh_rebuild() { Persistence::new(project.path().to_path_buf()), ) .unwrap(); - r.block_on(db.rebuild_from_text(project.path().to_path_buf(), None)).expect("rebuild"); + r.block_on(db.rebuild_from_text(project.path().to_path_buf(), None)) + .expect("rebuild"); // The named CHECK metadata survived: DROP CONSTRAINT by name resolves. r.block_on(db.alter_drop_constraint( @@ -878,7 +973,9 @@ fn e2e_describe_shows_table_level_constraints() { "events: {events:?}" ); - let desc = r.block_on(db.describe_table("T".to_string())).expect("describe"); + let desc = r + .block_on(db.describe_table("T".to_string())) + .expect("describe"); assert_eq!( desc.unique_constraints, vec![vec!["a".to_string(), "b".to_string()]], @@ -890,7 +987,9 @@ fn e2e_describe_shows_table_level_constraints() { .map(|c| (c.name.clone(), c.expr.clone())) .collect(); assert!( - checks.iter().any(|(n, e)| n.is_none() && e.contains("a < b")), + checks + .iter() + .any(|(n, e)| n.is_none() && e.contains("a < b")), "unnamed table CHECK surfaced: {checks:?}" ); assert!( @@ -972,8 +1071,14 @@ fn e2e_rename_table_with_rows_csv_follows_and_survives_rebuild() { tables.contains(&"Purchases".to_string()) && !tables.contains(&"Orders".to_string()), "the table is now Purchases, not Orders: {tables:?}" ); - assert!(csv_path(&project, "Purchases").exists(), "data/Purchases.csv written"); - assert!(!csv_path(&project, "Orders").exists(), "data/Orders.csv removed"); + assert!( + csv_path(&project, "Purchases").exists(), + "data/Purchases.csv written" + ); + assert!( + !csv_path(&project, "Orders").exists(), + "data/Orders.csv removed" + ); let rows = r .block_on(db.query_data("Purchases".to_string(), None, None)) @@ -1052,7 +1157,10 @@ fn e2e_rename_table_with_table_qualified_check_survives_fresh_rebuild() { ], Some("i".into()), )); - assert!(bad_after.is_err(), "the rewritten CHECK enforces after a fresh rebuild"); + assert!( + bad_after.is_err(), + "the rewritten CHECK enforces after a fresh rebuild" + ); } #[test] @@ -1077,7 +1185,9 @@ fn e2e_rename_fk_parent_updates_metadata_and_still_enforces() { ); // The child's outbound relationship now points at the new parent name. - let c = r.block_on(db.describe_table("C".to_string())).expect("describe C"); + let c = r + .block_on(db.describe_table("C".to_string())) + .expect("describe C"); assert_eq!(c.outbound_relationships.len(), 1); assert_eq!(c.outbound_relationships[0].other_table, "Parent"); @@ -1129,7 +1239,9 @@ fn e2e_rename_fk_child_updates_metadata_and_still_enforces() { ); // The parent's inbound relationship now names the renamed child. - let p = r.block_on(db.describe_table("P".to_string())).expect("describe P"); + let p = r + .block_on(db.describe_table("P".to_string())) + .expect("describe P"); assert_eq!(p.inbound_relationships.len(), 1); assert_eq!(p.inbound_relationships[0].other_table, "Child"); @@ -1168,7 +1280,9 @@ fn e2e_rename_self_referential_table_updates_both_ends() { ); // Both ends of the self-reference now name `Tree`. - let t = r.block_on(db.describe_table("Tree".to_string())).expect("describe Tree"); + let t = r + .block_on(db.describe_table("Tree".to_string())) + .expect("describe Tree"); assert_eq!(t.outbound_relationships[0].other_table, "Tree"); assert_eq!(t.inbound_relationships[0].other_table, "Tree"); @@ -1216,7 +1330,9 @@ fn e2e_rename_table_keeps_its_index_with_a_stale_name() { "events: {events:?}" ); - let u = r.block_on(db.describe_table("Users".to_string())).expect("describe Users"); + let u = r + .block_on(db.describe_table("Users".to_string())) + .expect("describe Users"); assert_eq!(u.indexes.len(), 1, "the index followed the rename"); assert_eq!( u.indexes[0].name, "T_email_idx", @@ -1226,7 +1342,9 @@ fn e2e_rename_table_keeps_its_index_with_a_stale_name() { // Survives a fresh rebuild (recreated from IndexSchema on table Users). let db = fresh_rebuild(db, &project, &r); - let u = r.block_on(db.describe_table("Users".to_string())).expect("describe Users"); + let u = r + .block_on(db.describe_table("Users".to_string())) + .expect("describe Users"); assert_eq!(u.indexes.len(), 1); assert_eq!(u.indexes[0].name, "T_email_idx"); } @@ -1248,14 +1366,20 @@ fn e2e_rename_table_is_one_undo_step() { assert!(table_names(&db, &r).contains(&"Purchases".to_string())); // One undo reverts the rename. - assert!(r.block_on(db.undo()).expect("undo").is_some(), "rename was one undo step"); + assert!( + r.block_on(db.undo()).expect("undo").is_some(), + "rename was one undo step" + ); let tables = table_names(&db, &r); assert!( tables.contains(&"Orders".to_string()) && !tables.contains(&"Purchases".to_string()), "undo restored the old table name: {tables:?}" ); assert_eq!( - r.block_on(db.query_data("Orders".to_string(), None, None)).expect("query").rows.len(), + r.block_on(db.query_data("Orders".to_string(), None, None)) + .expect("query") + .rows + .len(), 1, "the row is back under the old name" ); @@ -1286,19 +1410,23 @@ fn e2e_rename_table_refusals() { r.block_on(run_replay(&db, project.path(), "setup.commands")); assert!( - r.block_on(db.rename_table("T".into(), "X".into(), Some("rn".into()))).is_err(), + r.block_on(db.rename_table("T".into(), "X".into(), Some("rn".into()))) + .is_err(), "rename to an existing other table is refused" ); assert!( - r.block_on(db.rename_table("T".into(), "T".into(), Some("rn".into()))).is_err(), + r.block_on(db.rename_table("T".into(), "T".into(), Some("rn".into()))) + .is_err(), "rename to the same name is refused" ); assert!( - r.block_on(db.rename_table("Ghost".into(), "G".into(), Some("rn".into()))).is_err(), + r.block_on(db.rename_table("Ghost".into(), "G".into(), Some("rn".into()))) + .is_err(), "rename of a non-existent table is refused" ); assert!( - r.block_on(db.rename_table("T".into(), "__rdbms_evil".into(), Some("rn".into()))).is_err(), + r.block_on(db.rename_table("T".into(), "__rdbms_evil".into(), Some("rn".into()))) + .is_err(), "rename to an internal table name is refused at the executor" ); @@ -1315,7 +1443,8 @@ fn e2e_rename_table_refusals() { ); } assert!( - r.block_on(db.rename_table("T".into(), "x".into(), Some("rn".into()))).is_err(), + r.block_on(db.rename_table("T".into(), "x".into(), Some("rn".into()))) + .is_err(), "rename to a name colliding case-insensitively with another table (X) is refused" ); @@ -1348,7 +1477,10 @@ fn e2e_alter_column_set_not_null_enforced() { .expect("write"); let events = r.block_on(run_replay(&db, project.path(), "a.commands")); assert!( - matches!(events.last(), Some(AppEvent::ReplayCompleted { count: 4, .. })), + matches!( + events.last(), + Some(AppEvent::ReplayCompleted { count: 4, .. }) + ), "set not null on a clean column succeeds; events: {events:?}" ); assert!( @@ -1390,7 +1522,10 @@ fn e2e_alter_column_drop_not_null_allows_nulls() { .expect("write"); let events = r.block_on(run_replay(&db, project.path(), "a.commands")); assert!( - matches!(events.last(), Some(AppEvent::ReplayCompleted { count: 4, .. })), + matches!( + events.last(), + Some(AppEvent::ReplayCompleted { count: 4, .. }) + ), "events: {events:?}" ); r.block_on(db.insert( @@ -1416,7 +1551,10 @@ fn e2e_alter_column_set_default_applies() { .expect("write"); let events = r.block_on(run_replay(&db, project.path(), "a.commands")); assert!( - matches!(events.last(), Some(AppEvent::ReplayCompleted { count: 3, .. })), + matches!( + events.last(), + Some(AppEvent::ReplayCompleted { count: 3, .. }) + ), "events: {events:?}" ); r.block_on(db.insert( @@ -1462,7 +1600,10 @@ fn e2e_alter_column_drop_default_removes_it() { .expect("write"); let events = r.block_on(run_replay(&db, project.path(), "a.commands")); assert!( - matches!(events.last(), Some(AppEvent::ReplayCompleted { count: 4, .. })), + matches!( + events.last(), + Some(AppEvent::ReplayCompleted { count: 4, .. }) + ), "events: {events:?}" ); r.block_on(db.insert( @@ -1499,7 +1640,10 @@ fn e2e_alter_column_set_data_type_converts() { .expect("write"); let events = r.block_on(run_replay(&db, project.path(), "a.commands")); assert!( - matches!(events.last(), Some(AppEvent::ReplayCompleted { count: 4, .. })), + matches!( + events.last(), + Some(AppEvent::ReplayCompleted { count: 4, .. }) + ), "events: {events:?}" ); assert_eq!( diff --git a/tests/it/sql_create_index.rs b/tests/it/sql_create_index.rs index 442022d..ec3ccb9 100644 --- a/tests/it/sql_create_index.rs +++ b/tests/it/sql_create_index.rs @@ -21,8 +21,7 @@ fn rt() -> tokio::runtime::Runtime { fn open(undo: bool) -> (project::Project, Database, tempfile::TempDir) { let dir = tempfile::tempdir().expect("create tempdir"); - let project = - project::open_or_create(None, Some(dir.path())).expect("open or create project"); + let project = project::open_or_create(None, Some(dir.path())).expect("open or create project"); let persistence = Persistence::new(project.path().to_path_buf()); let db = Database::open_with_persistence_and_undo(project.db_path(), persistence, undo) .expect("open db with persistence"); @@ -33,7 +32,10 @@ fn open(undo: bool) -> (project::Project, Database, tempfile::TempDir) { fn make_t(db: &Database, r: &tokio::runtime::Runtime) { r.block_on(db.sql_create_table( "T".to_string(), - vec![ColumnSpec::new("id", Type::Int), ColumnSpec::new("email", Type::Text)], + vec![ + ColumnSpec::new("id", Type::Int), + ColumnSpec::new("email", Type::Text), + ], vec!["id".to_string()], vec![], vec![], @@ -48,8 +50,13 @@ fn insert_row(db: &Database, r: &tokio::runtime::Runtime, id: i64, email: &str) r.block_on(db.insert( "T".to_string(), Some(vec!["id".to_string(), "email".to_string()]), - vec![Value::Number(id.to_string()), Value::Text(email.to_string())], - Some(format!("insert into T (id, email) values ({id}, '{email}')")), + vec![ + Value::Number(id.to_string()), + Value::Text(email.to_string()), + ], + Some(format!( + "insert into T (id, email) values ({id}, '{email}')" + )), )) .is_ok() } @@ -79,7 +86,10 @@ fn create_plain_index() { )) .expect("create index"); assert!(matches!(out, CreateIndexOutcome::Created(_))); - assert_eq!(index(&db, &r, "ix"), Some((vec!["email".to_string()], false))); + assert_eq!( + index(&db, &r, "ix"), + Some((vec!["email".to_string()], false)) + ); } #[test] @@ -97,14 +107,20 @@ fn create_unique_index_round_trips_and_survives_rebuild_and_enforces() { )) .expect("create unique index"); // Reported as unique. - assert_eq!(index(&db, &r, "ux"), Some((vec!["email".to_string()], true))); + assert_eq!( + index(&db, &r, "ux"), + Some((vec!["email".to_string()], true)) + ); // Persisted to project.yaml as a unique index. let yaml = std::fs::read_to_string(p.path().join("project.yaml")).expect("read project.yaml"); assert!(yaml.contains("unique: true"), "project.yaml:\n{yaml}"); // Uniqueness is enforced by the engine. assert!(insert_row(&db, &r, 1, "a@x")); - assert!(!insert_row(&db, &r, 2, "a@x"), "duplicate email refused by the unique index"); + assert!( + !insert_row(&db, &r, 2, "a@x"), + "duplicate email refused by the unique index" + ); // Rebuild from the text artifacts: the index comes back UNIQUE // (the rebuild re-emits CREATE UNIQUE INDEX), not demoted to plain. @@ -116,7 +132,10 @@ fn create_unique_index_round_trips_and_survives_rebuild_and_enforces() { "the unique flag survived rebuild" ); // Still enforced after rebuild. - assert!(!insert_row(&db, &r, 3, "a@x"), "uniqueness enforced after rebuild too"); + assert!( + !insert_row(&db, &r, 3, "a@x"), + "uniqueness enforced after rebuild too" + ); } #[test] @@ -264,7 +283,10 @@ fn plain_duplicate_name_errors() { false, Some("create index ix on T (id)".to_string()), )); - assert!(res.is_err(), "duplicate index name without IF NOT EXISTS errors"); + assert!( + res.is_err(), + "duplicate index name without IF NOT EXISTS errors" + ); } #[test] @@ -307,7 +329,10 @@ fn plain_and_unique_over_the_same_columns_are_not_duplicates() { false, Some("create index ix_plain2 on T (email)".to_string()), )); - assert!(res.is_err(), "a second plain index over the same columns is redundant"); + assert!( + res.is_err(), + "a second plain index over the same columns is redundant" + ); } #[test] @@ -328,7 +353,10 @@ fn create_index_on_an_internal_table_is_refused_on_both_surfaces() { false, Some("create index bad on __rdbms_playground_columns (table_name)".to_string()), )); - assert!(sql.is_err(), "SQL CREATE INDEX on an internal table is refused"); + assert!( + sql.is_err(), + "SQL CREATE INDEX on an internal table is refused" + ); // Simple `add index` on an internal table → error (same guard). let dsl = r.block_on(db.add_index( Some("bad2".to_string()), @@ -336,7 +364,10 @@ fn create_index_on_an_internal_table_is_refused_on_both_surfaces() { vec!["table_name".to_string()], Some("add index as bad2 on __rdbms_playground_columns (table_name)".to_string()), )); - assert!(dsl.is_err(), "simple add index on an internal table is refused"); + assert!( + dsl.is_err(), + "simple add index on an internal table is refused" + ); } #[test] @@ -355,6 +386,9 @@ fn create_index_is_one_undo_step() { .expect("create index"); assert!(index(&db, &r, "ix").is_some()); // One undo removes the index. - assert!(r.block_on(db.undo()).expect("undo").is_some(), "the create was one undo step"); + assert!( + r.block_on(db.undo()).expect("undo").is_some(), + "the create was one undo step" + ); assert!(index(&db, &r, "ix").is_none(), "undo removed the index"); } diff --git a/tests/it/sql_create_table.rs b/tests/it/sql_create_table.rs index e2e6474..d3a4b7d 100644 --- a/tests/it/sql_create_table.rs +++ b/tests/it/sql_create_table.rs @@ -31,8 +31,7 @@ fn rt() -> tokio::runtime::Runtime { fn open(undo: bool) -> (project::Project, Database, tempfile::TempDir) { let dir = tempfile::tempdir().expect("create tempdir"); - let project = - project::open_or_create(None, Some(dir.path())).expect("open or create project"); + let project = project::open_or_create(None, Some(dir.path())).expect("open or create project"); let persistence = Persistence::new(project.path().to_path_buf()); let db = Database::open_with_persistence_and_undo(project.db_path(), persistence, undo) .expect("open db with persistence"); @@ -178,7 +177,7 @@ fn if_not_exists_is_a_noop_when_table_exists() { vec![], // no composite UNIQUE vec![], // no table CHECK vec![], // no FK - true, // IF NOT EXISTS + true, // IF NOT EXISTS Some("create table if not exists T (id int)".to_string()), )) .expect("second create should succeed as a no-op"); @@ -245,7 +244,10 @@ fn check_constraint_is_enforced() { let r = rt(); r.block_on(db.sql_create_table( "T".to_string(), - vec![ColumnSpec::new("id", Type::Serial), col_check("price", Type::Real, "price >= 0")], + vec![ + ColumnSpec::new("id", Type::Serial), + col_check("price", Type::Real, "price >= 0"), + ], vec!["id".to_string()], vec![], vec![], // no table CHECK @@ -301,8 +303,16 @@ fn default_is_applied_when_column_omitted() { let data = r .block_on(db.query_data("T".to_string(), None, None)) .expect("query"); - let n_idx = data.columns.iter().position(|c| c == "n").expect("n column"); - assert_eq!(data.rows[0][n_idx].as_deref(), Some("7"), "DEFAULT 7 applied"); + 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" + ); } #[test] @@ -311,7 +321,10 @@ fn composite_unique_is_enforced() { let r = rt(); r.block_on(db.sql_create_table( "T".to_string(), - vec![ColumnSpec::new("a", Type::Int), ColumnSpec::new("b", Type::Int)], + vec![ + ColumnSpec::new("a", Type::Int), + ColumnSpec::new("b", Type::Int), + ], vec![], vec![vec!["a".to_string(), "b".to_string()]], vec![], // no table CHECK @@ -329,8 +342,12 @@ fn composite_unique_is_enforced() { ) }; r.block_on(ins("1", "2")).expect("first (1,2)"); - assert!(r.block_on(ins("1", "2")).is_err(), "UNIQUE(a,b) rejects duplicate (1,2)"); - r.block_on(ins("1", "3")).expect("distinct (1,3) is allowed"); + assert!( + r.block_on(ins("1", "2")).is_err(), + "UNIQUE(a,b) rejects duplicate (1,2)" + ); + r.block_on(ins("1", "3")) + .expect("distinct (1,3) is allowed"); } #[test] @@ -340,25 +357,27 @@ fn check_default_and_composite_unique_survive_rebuild() { // must all be reconstructed from project.yaml on rebuild. let (p, db, _d) = open(false); let r = rt(); - r.block_on(db.sql_create_table( - "T".to_string(), - vec![ - ColumnSpec::new("a", Type::Int), - ColumnSpec::new("b", Type::Int), - col_check("price", Type::Real, "price >= 0"), - col_default("n", Type::Int, "7"), - ], - vec![], - vec![vec!["a".to_string(), "b".to_string()]], - vec![], // no table CHECK - vec![], // no FK - false, - Some( - "create table T (a int, b int, price real check (price >= 0), \ + r.block_on( + db.sql_create_table( + "T".to_string(), + vec![ + ColumnSpec::new("a", Type::Int), + ColumnSpec::new("b", Type::Int), + col_check("price", Type::Real, "price >= 0"), + col_default("n", Type::Int, "7"), + ], + vec![], + vec![vec!["a".to_string(), "b".to_string()]], + vec![], // no table CHECK + vec![], // no FK + false, + Some( + "create table T (a int, b int, price real check (price >= 0), \ n int default 7, unique (a, b))" - .to_string(), + .to_string(), + ), ), - )) + ) .expect("create"); r.block_on(db.rebuild_from_text(p.path().to_path_buf(), None)) @@ -377,16 +396,30 @@ fn check_default_and_composite_unique_survive_rebuild() { ) }; // CHECK survived: a negative price is rejected. - assert!(r.block_on(ins("1", "1", "-1")).is_err(), "CHECK survived rebuild"); + assert!( + r.block_on(ins("1", "1", "-1")).is_err(), + "CHECK survived rebuild" + ); // A valid row inserts; DEFAULT n=7 survived. r.block_on(ins("1", "1", "5")).expect("valid row"); let data = r .block_on(db.query_data("T".to_string(), None, None)) .expect("query"); - let n_idx = data.columns.iter().position(|c| c == "n").expect("n column"); - assert_eq!(data.rows[0][n_idx].as_deref(), Some("7"), "DEFAULT survived rebuild"); + 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" + ); // Composite UNIQUE survived: (1,1) again is rejected. - assert!(r.block_on(ins("1", "1", "5")).is_err(), "composite UNIQUE survived rebuild"); + assert!( + r.block_on(ins("1", "1", "5")).is_err(), + "composite UNIQUE survived rebuild" + ); } #[test] @@ -398,11 +431,14 @@ fn table_level_check_is_enforced() { let r = rt(); r.block_on(db.sql_create_table( "T".to_string(), - vec![ColumnSpec::new("a", Type::Int), ColumnSpec::new("b", Type::Int)], + vec![ + ColumnSpec::new("a", Type::Int), + ColumnSpec::new("b", Type::Int), + ], vec![], vec![], // no composite UNIQUE vec!["a < b".to_string()], // table-level CHECK - vec![], // no FK + vec![], // no FK false, Some("create table T (a int, b int, check (a < b))".to_string()), )) @@ -416,8 +452,14 @@ fn table_level_check_is_enforced() { ) }; r.block_on(ins("1", "2")).expect("(1,2) satisfies a < b"); - assert!(r.block_on(ins("2", "1")).is_err(), "CHECK (a < b) rejects (2,1)"); - assert!(r.block_on(ins("3", "3")).is_err(), "CHECK (a < b) rejects (3,3)"); + assert!( + r.block_on(ins("2", "1")).is_err(), + "CHECK (a < b) rejects (2,1)" + ); + assert!( + r.block_on(ins("3", "3")).is_err(), + "CHECK (a < b) rejects (3,3)" + ); } #[test] @@ -451,9 +493,16 @@ fn multiple_table_level_checks_all_enforced() { Some("insert".to_string()), ) }; - r.block_on(ins("1", "2", "3")).expect("(1,2,3) satisfies both checks"); - assert!(r.block_on(ins("2", "1", "3")).is_err(), "first CHECK (a < b) enforced"); - assert!(r.block_on(ins("1", "3", "2")).is_err(), "second CHECK (b < c) enforced"); + r.block_on(ins("1", "2", "3")) + .expect("(1,2,3) satisfies both checks"); + assert!( + r.block_on(ins("2", "1", "3")).is_err(), + "first CHECK (a < b) enforced" + ); + assert!( + r.block_on(ins("1", "3", "2")).is_err(), + "second CHECK (b < c) enforced" + ); } #[test] @@ -467,7 +516,10 @@ fn dropping_a_table_clears_its_table_check_metadata() { let make = || { db.sql_create_table( "T".to_string(), - vec![ColumnSpec::new("a", Type::Int), ColumnSpec::new("b", Type::Int)], + vec![ + ColumnSpec::new("a", Type::Int), + ColumnSpec::new("b", Type::Int), + ], vec![], vec![], // no composite UNIQUE vec!["a < b".to_string()], @@ -479,7 +531,8 @@ fn dropping_a_table_clears_its_table_check_metadata() { r.block_on(make()).expect("first create"); r.block_on(db.drop_table("T".to_string(), Some("drop table T".to_string()))) .expect("drop"); - r.block_on(make()).expect("re-create must not collide on orphaned CHECK metadata"); + r.block_on(make()) + .expect("re-create must not collide on orphaned CHECK metadata"); // The re-created CHECK is enforced (and there is exactly one of it). let ins = |a: &str, b: &str| { db.insert( @@ -490,7 +543,10 @@ fn dropping_a_table_clears_its_table_check_metadata() { ) }; r.block_on(ins("1", "2")).expect("(1,2) valid"); - assert!(r.block_on(ins("2", "1")).is_err(), "CHECK enforced after re-create"); + assert!( + r.block_on(ins("2", "1")).is_err(), + "CHECK enforced after re-create" + ); } #[test] @@ -505,7 +561,10 @@ fn table_level_check_survives_a_rebuild_triggering_column_add() { let r = rt(); r.block_on(db.sql_create_table( "T".to_string(), - vec![ColumnSpec::new("a", Type::Int), ColumnSpec::new("b", Type::Int)], + vec![ + ColumnSpec::new("a", Type::Int), + ColumnSpec::new("b", Type::Int), + ], vec![], vec![], // no composite UNIQUE vec!["a < b".to_string()], @@ -518,8 +577,12 @@ fn table_level_check_survives_a_rebuild_triggering_column_add() { // A UNIQUE column forces the rebuild path (ADR-0029 §6). let mut c = ColumnSpec::new("c", Type::Int); c.unique = true; - r.block_on(db.add_column("T".to_string(), c, Some("add column T: c(int) unique".to_string()))) - .expect("add column via rebuild"); + r.block_on(db.add_column( + "T".to_string(), + c, + Some("add column T: c(int) unique".to_string()), + )) + .expect("add column via rebuild"); let ins = |a: &str, b: &str, c: &str| { db.insert( @@ -534,8 +597,12 @@ fn table_level_check_survives_a_rebuild_triggering_column_add() { ) }; // Engine still enforces the CHECK right after the rebuild. - r.block_on(ins("1", "2", "10")).expect("(1,2) valid after column add"); - assert!(r.block_on(ins("2", "1", "20")).is_err(), "CHECK survived the column-add rebuild"); + r.block_on(ins("1", "2", "10")) + .expect("(1,2) valid after column add"); + assert!( + r.block_on(ins("2", "1", "20")).is_err(), + "CHECK survived the column-add rebuild" + ); // And the metadata survived too: a fresh rebuild from project.yaml // re-emits the CHECK (it would be lost if the rebuild primitive had @@ -557,7 +624,10 @@ fn table_level_check_survives_rebuild() { let r = rt(); r.block_on(db.sql_create_table( "T".to_string(), - vec![ColumnSpec::new("a", Type::Int), ColumnSpec::new("b", Type::Int)], + vec![ + ColumnSpec::new("a", Type::Int), + ColumnSpec::new("b", Type::Int), + ], vec![], vec![], // no composite UNIQUE vec!["a < b".to_string()], @@ -578,7 +648,8 @@ fn table_level_check_survives_rebuild() { Some("insert".to_string()), ) }; - r.block_on(ins("1", "2")).expect("(1,2) still valid after rebuild"); + r.block_on(ins("1", "2")) + .expect("(1,2) still valid after rebuild"); assert!( r.block_on(ins("5", "4")).is_err(), "table-level CHECK survived rebuild via the metadata table" @@ -645,10 +716,13 @@ fn plain_create_errors_when_table_exists() { vec![], // no composite UNIQUE vec![], // no table CHECK vec![], // no FK - false, // no IF NOT EXISTS + false, // no IF NOT EXISTS Some("create table T (id int)".to_string()), )); - assert!(err.is_err(), "re-creating an existing table without IF NOT EXISTS errors"); + assert!( + err.is_err(), + "re-creating an existing table without IF NOT EXISTS errors" + ); } #[test] @@ -666,12 +740,18 @@ fn sql_create_table_is_one_undo_step() { Some("create table T (id int)".to_string()), )) .expect("create"); - 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 undone = r.block_on(db.undo()).expect("undo call"); assert!(undone.is_some(), "the CREATE TABLE recorded one undo step"); assert!( - !r.block_on(db.list_tables()).unwrap().contains(&"T".to_string()), + !r.block_on(db.list_tables()) + .unwrap() + .contains(&"T".to_string()), "table is gone after a single undo" ); } @@ -726,7 +806,11 @@ fn serial_pk_first_column_autoincrements_after_rebuild() { insert_row(&db, &r, "c"); assert_eq!( ids(&db, &r), - vec![Some("1".to_string()), Some("2".to_string()), Some("3".to_string())] + vec![ + Some("1".to_string()), + Some("2".to_string()), + Some("3".to_string()) + ] ); } @@ -755,13 +839,20 @@ fn serial_pk_non_first_column_autoincrements_after_rebuild() { .expect("create"); insert_row(&db, &r, "a"); insert_row(&db, &r, "b"); - assert_eq!(ids(&db, &r), vec![Some("1".to_string()), Some("2".to_string())]); + assert_eq!( + ids(&db, &r), + vec![Some("1".to_string()), Some("2".to_string())] + ); r.block_on(db.rebuild_from_text(p.path().to_path_buf(), None)) .expect("rebuild"); insert_row(&db, &r, "c"); assert_eq!( ids(&db, &r), - vec![Some("1".to_string()), Some("2".to_string()), Some("3".to_string())], + vec![ + Some("1".to_string()), + Some("2".to_string()), + Some("3".to_string()) + ], "serial keeps autoincrement after a rebuild even as a non-first column" ); } @@ -781,7 +872,10 @@ fn dropping_a_column_a_table_check_references_fails_cleanly() { let r = rt(); r.block_on(db.sql_create_table( "T".to_string(), - vec![ColumnSpec::new("a", Type::Int), ColumnSpec::new("b", Type::Int)], + vec![ + ColumnSpec::new("a", Type::Int), + ColumnSpec::new("b", Type::Int), + ], vec![], vec![], // no composite UNIQUE vec!["a < b".to_string()], @@ -797,14 +891,20 @@ fn dropping_a_column_a_table_check_references_fails_cleanly() { false, Some("drop column T: a".to_string()), )); - assert!(dropped.is_err(), "dropping a column a CHECK references is rejected"); + assert!( + dropped.is_err(), + "dropping a column a CHECK references is rejected" + ); // The table is intact: both columns survive (rollback) ... let desc = r .block_on(db.describe_table("T".to_string())) .expect("describe still works"); assert_eq!( - desc.columns.iter().map(|c| c.name.clone()).collect::>(), + desc.columns + .iter() + .map(|c| c.name.clone()) + .collect::>(), vec!["a".to_string(), "b".to_string()], "the failed drop rolled back — no half-migrated table" ); @@ -817,8 +917,12 @@ fn dropping_a_column_a_table_check_references_fails_cleanly() { Some("insert".to_string()), ) }; - r.block_on(ins("1", "2")).expect("(1,2) valid — table survived intact"); - assert!(r.block_on(ins("2", "1")).is_err(), "CHECK still enforced after the failed drop"); + r.block_on(ins("1", "2")) + .expect("(1,2) valid — table survived intact"); + assert!( + r.block_on(ins("2", "1")).is_err(), + "CHECK still enforced after the failed drop" + ); } // ================================================================= @@ -849,7 +953,10 @@ fn fk(child_column: &str, parent_table: &str, parent_column: Option<&str>) -> Sq fn make_parent(db: &Database, r: &tokio::runtime::Runtime) { r.block_on(db.sql_create_table( "parent".to_string(), - vec![ColumnSpec::new("id", Type::Serial), ColumnSpec::new("label", Type::Text)], + vec![ + ColumnSpec::new("id", Type::Serial), + ColumnSpec::new("label", Type::Text), + ], vec!["id".to_string()], vec![], vec![], @@ -878,13 +985,18 @@ fn foreign_key_is_enforced() { make_parent(&db, &r); r.block_on(db.sql_create_table( "child".to_string(), - vec![ColumnSpec::new("id", Type::Serial), ColumnSpec::new("pid", Type::Int)], + vec![ + ColumnSpec::new("id", Type::Serial), + ColumnSpec::new("pid", Type::Int), + ], vec!["id".to_string()], vec![], vec![], vec![fk("pid", "parent", Some("id"))], false, - Some("create table child (id serial primary key, pid int references parent(id))".to_string()), + Some( + "create table child (id serial primary key, pid int references parent(id))".to_string(), + ), )) .expect("create child with FK"); @@ -914,26 +1026,46 @@ fn foreign_key_creates_named_relationship_visible_in_describe() { make_parent(&db, &r); r.block_on(db.sql_create_table( "child".to_string(), - vec![ColumnSpec::new("id", Type::Serial), ColumnSpec::new("pid", Type::Int)], + vec![ + ColumnSpec::new("id", Type::Serial), + ColumnSpec::new("pid", Type::Int), + ], vec!["id".to_string()], vec![], vec![], vec![fk("pid", "parent", Some("id"))], false, - Some("create table child (id serial primary key, pid int references parent(id))".to_string()), + Some( + "create table child (id serial primary key, pid int references parent(id))".to_string(), + ), )) .expect("create child with FK"); // The child has an outbound relationship; the parent an inbound one. - let child = r.block_on(db.describe_table("child".to_string())).expect("describe child"); - assert_eq!(child.outbound_relationships.len(), 1, "child references parent"); + let child = r + .block_on(db.describe_table("child".to_string())) + .expect("describe child"); + assert_eq!( + child.outbound_relationships.len(), + 1, + "child references parent" + ); let rel = &child.outbound_relationships[0]; - assert_eq!(rel.name, "parent_id_to_child_pid", "auto-named per ADR-0013"); + assert_eq!( + rel.name, "parent_id_to_child_pid", + "auto-named per ADR-0013" + ); assert_eq!(rel.other_table, "parent"); assert_eq!(rel.local_columns, vec!["pid".to_string()]); - let parent = r.block_on(db.describe_table("parent".to_string())).expect("describe parent"); - assert_eq!(parent.inbound_relationships.len(), 1, "parent is referenced by child"); + 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" + ); } #[test] @@ -954,7 +1086,9 @@ fn explicit_constraint_name_is_used() { Some("create table child (id serial primary key, pid int, constraint child_to_parent foreign key (pid) references parent(id))".to_string()), )) .expect("create child with named FK"); - let child = r.block_on(db.describe_table("child".to_string())).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"); } @@ -965,7 +1099,10 @@ fn bare_references_resolves_to_parent_single_column_pk() { make_parent(&db, &r); r.block_on(db.sql_create_table( "child".to_string(), - vec![ColumnSpec::new("id", Type::Serial), ColumnSpec::new("pid", Type::Int)], + vec![ + ColumnSpec::new("id", Type::Serial), + ColumnSpec::new("pid", Type::Int), + ], vec!["id".to_string()], vec![], vec![], @@ -974,8 +1111,14 @@ fn bare_references_resolves_to_parent_single_column_pk() { Some("create table child (id serial primary key, pid int references parent)".to_string()), )) .expect("create child with bare REFERENCES"); - let child = r.block_on(db.describe_table("child".to_string())).expect("describe"); - assert_eq!(child.outbound_relationships[0].other_columns, vec!["id".to_string()], "resolved to parent PK"); + 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" + ); } #[test] @@ -984,7 +1127,10 @@ fn self_referencing_foreign_key_is_enforced() { let r = rt(); r.block_on(db.sql_create_table( "emp".to_string(), - vec![ColumnSpec::new("id", Type::Int), ColumnSpec::new("mgr", Type::Int)], + vec![ + ColumnSpec::new("id", Type::Int), + ColumnSpec::new("mgr", Type::Int), + ], vec!["id".to_string()], vec![], vec![], @@ -1003,11 +1149,19 @@ fn self_referencing_foreign_key_is_enforced() { ) }, ); - db.insert("emp".to_string(), Some(cols), vals, Some("insert".to_string())) + db.insert( + "emp".to_string(), + Some(cols), + vals, + Some("insert".to_string()), + ) }; r.block_on(ins("1", None)).expect("root (mgr NULL)"); r.block_on(ins("2", Some("1"))).expect("emp 2 reports to 1"); - assert!(r.block_on(ins("3", Some("99"))).is_err(), "self-FK rejects mgr=99"); + assert!( + r.block_on(ins("3", Some("99"))).is_err(), + "self-FK rejects mgr=99" + ); } #[test] @@ -1016,17 +1170,28 @@ fn foreign_key_type_mismatch_is_rejected() { let r = rt(); make_parent(&db, &r); // parent.id is serial -> fk_target_type int // child.pid declared text -> incompatible with int. - let res = r.block_on(db.sql_create_table( - "child".to_string(), - vec![ColumnSpec::new("id", Type::Serial), ColumnSpec::new("pid", Type::Text)], - vec!["id".to_string()], - vec![], - vec![], - vec![fk("pid", "parent", Some("id"))], - false, - Some("create table child (id serial primary key, pid text references parent(id))".to_string()), - )); - assert!(res.is_err(), "FK column type must match the parent's fk_target_type"); + let res = r.block_on( + db.sql_create_table( + "child".to_string(), + vec![ + ColumnSpec::new("id", Type::Serial), + ColumnSpec::new("pid", Type::Text), + ], + vec!["id".to_string()], + vec![], + vec![], + vec![fk("pid", "parent", Some("id"))], + false, + Some( + "create table child (id serial primary key, pid text references parent(id))" + .to_string(), + ), + ), + ); + assert!( + res.is_err(), + "FK column type must match the parent's fk_target_type" + ); } #[test] @@ -1035,7 +1200,10 @@ fn foreign_key_to_non_pk_column_is_rejected() { let r = rt(); r.block_on(db.sql_create_table( "parent".to_string(), - vec![ColumnSpec::new("id", Type::Serial), ColumnSpec::new("label", Type::Text)], + vec![ + ColumnSpec::new("id", Type::Serial), + ColumnSpec::new("label", Type::Text), + ], vec!["id".to_string()], vec![], vec![], @@ -1044,16 +1212,24 @@ fn foreign_key_to_non_pk_column_is_rejected() { Some("create table parent (id serial primary key, label text)".to_string()), )) .expect("create parent"); - let res = r.block_on(db.sql_create_table( - "child".to_string(), - vec![ColumnSpec::new("id", Type::Serial), ColumnSpec::new("plabel", Type::Text)], - vec!["id".to_string()], - vec![], - vec![], - vec![fk("plabel", "parent", Some("label"))], // label is not a PK - false, - Some("create table child (id serial primary key, plabel text references parent(label))".to_string()), - )); + let res = r.block_on( + db.sql_create_table( + "child".to_string(), + vec![ + ColumnSpec::new("id", Type::Serial), + ColumnSpec::new("plabel", Type::Text), + ], + vec!["id".to_string()], + vec![], + vec![], + vec![fk("plabel", "parent", Some("label"))], // label is not a PK + false, + Some( + "create table child (id serial primary key, plabel text references parent(label))" + .to_string(), + ), + ), + ); assert!(res.is_err(), "FK must target a primary key"); } @@ -1064,16 +1240,22 @@ fn foreign_key_survives_rebuild() { make_parent(&db, &r); r.block_on(db.sql_create_table( "child".to_string(), - vec![ColumnSpec::new("id", Type::Serial), ColumnSpec::new("pid", Type::Int)], + vec![ + ColumnSpec::new("id", Type::Serial), + ColumnSpec::new("pid", Type::Int), + ], vec!["id".to_string()], vec![], vec![], vec![fk("pid", "parent", Some("id"))], false, - Some("create table child (id serial primary key, pid int references parent(id))".to_string()), + Some( + "create table child (id serial primary key, pid int references parent(id))".to_string(), + ), )) .expect("create child with FK"); - 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"); insert_parent_row(&db, &r); assert!( @@ -1095,21 +1277,37 @@ fn create_table_with_fk_is_one_undo_step() { make_parent(&db, &r); r.block_on(db.sql_create_table( "child".to_string(), - vec![ColumnSpec::new("id", Type::Serial), ColumnSpec::new("pid", Type::Int)], + vec![ + ColumnSpec::new("id", Type::Serial), + ColumnSpec::new("pid", Type::Int), + ], vec!["id".to_string()], vec![], vec![], vec![fk("pid", "parent", Some("id"))], false, - Some("create table child (id serial primary key, pid int references parent(id))".to_string()), + Some( + "create table child (id serial primary key, pid int references parent(id))".to_string(), + ), )) .expect("create child with FK"); // One undo removes the child table AND its relationship row, so the // parent (now un-referenced) can be described without a dangling rel. - r.block_on(db.undo()).expect("undo").expect("a step was undone"); - assert!(!r.block_on(db.list_tables()).unwrap().contains(&"child".to_string())); - let parent = r.block_on(db.describe_table("parent".to_string())).expect("describe parent"); - assert!(parent.inbound_relationships.is_empty(), "the relationship was undone with the table"); + r.block_on(db.undo()) + .expect("undo") + .expect("a step was undone"); + assert!( + !r.block_on(db.list_tables()) + .unwrap() + .contains(&"child".to_string()) + ); + let parent = r + .block_on(db.describe_table("parent".to_string())) + .expect("describe parent"); + assert!( + parent.inbound_relationships.is_empty(), + "the relationship was undone with the table" + ); } #[test] @@ -1121,20 +1319,25 @@ fn foreign_key_on_delete_cascade_takes_effect() { make_parent(&db, &r); let mut spec = fk("pid", "parent", Some("id")); spec.on_delete = ReferentialAction::Cascade; - r.block_on(db.sql_create_table( - "child".to_string(), - vec![ColumnSpec::new("id", Type::Serial), ColumnSpec::new("pid", Type::Int)], - vec!["id".to_string()], - vec![], - vec![], - vec![spec], - false, - Some( - "create table child (id serial primary key, pid int references parent(id) \ + r.block_on( + db.sql_create_table( + "child".to_string(), + vec![ + ColumnSpec::new("id", Type::Serial), + ColumnSpec::new("pid", Type::Int), + ], + vec!["id".to_string()], + vec![], + vec![], + vec![spec], + false, + Some( + "create table child (id serial primary key, pid int references parent(id) \ on delete cascade)" - .to_string(), + .to_string(), + ), ), - )) + ) .expect("create child with ON DELETE CASCADE"); insert_parent_row(&db, &r); // id=1 r.block_on(db.insert( @@ -1154,7 +1357,10 @@ fn foreign_key_on_delete_cascade_takes_effect() { let child_rows = r .block_on(db.query_data("child".to_string(), None, None)) .expect("query child"); - assert!(child_rows.rows.is_empty(), "ON DELETE CASCADE removed the child row"); + assert!( + child_rows.rows.is_empty(), + "ON DELETE CASCADE removed the child row" + ); } #[test] @@ -1163,17 +1369,29 @@ fn foreign_key_to_unknown_parent_is_rejected() { let r = rt(); let res = r.block_on(db.sql_create_table( "child".to_string(), - vec![ColumnSpec::new("id", Type::Serial), ColumnSpec::new("pid", Type::Int)], + vec![ + ColumnSpec::new("id", Type::Serial), + ColumnSpec::new("pid", Type::Int), + ], vec!["id".to_string()], vec![], vec![], vec![fk("pid", "ghost", Some("id"))], // no such table false, - Some("create table child (id serial primary key, pid int references ghost(id))".to_string()), + Some( + "create table child (id serial primary key, pid int references ghost(id))".to_string(), + ), )); - assert!(res.is_err(), "a FK to a non-existent parent table is rejected"); + assert!( + res.is_err(), + "a FK to a non-existent parent table is rejected" + ); // And the failed create left nothing behind. - assert!(!r.block_on(db.list_tables()).unwrap().contains(&"child".to_string())); + assert!( + !r.block_on(db.list_tables()) + .unwrap() + .contains(&"child".to_string()) + ); } #[test] @@ -1182,7 +1400,10 @@ fn composite_pk_bare_reference_is_rejected() { let r = rt(); r.block_on(db.sql_create_table( "cp".to_string(), - vec![ColumnSpec::new("a", Type::Int), ColumnSpec::new("b", Type::Int)], + vec![ + ColumnSpec::new("a", Type::Int), + ColumnSpec::new("b", Type::Int), + ], vec!["a".to_string(), "b".to_string()], // composite PK vec![], vec![], @@ -1194,7 +1415,10 @@ fn composite_pk_bare_reference_is_rejected() { // A bare `REFERENCES cp` cannot disambiguate which PK column. let res = r.block_on(db.sql_create_table( "child".to_string(), - vec![ColumnSpec::new("id", Type::Serial), ColumnSpec::new("ref", Type::Int)], + vec![ + ColumnSpec::new("id", Type::Serial), + ColumnSpec::new("ref", Type::Int), + ], vec!["id".to_string()], vec![], vec![], @@ -1202,7 +1426,10 @@ fn composite_pk_bare_reference_is_rejected() { false, Some("create table child (id serial primary key, ref int references cp)".to_string()), )); - assert!(res.is_err(), "bare REFERENCES to a composite-PK parent must be rejected"); + assert!( + res.is_err(), + "bare REFERENCES to a composite-PK parent must be rejected" + ); } #[test] @@ -1216,27 +1443,43 @@ fn fk_survives_a_rebuild_triggering_column_add() { make_parent(&db, &r); r.block_on(db.sql_create_table( "child".to_string(), - vec![ColumnSpec::new("id", Type::Serial), ColumnSpec::new("pid", Type::Int)], + vec![ + ColumnSpec::new("id", Type::Serial), + ColumnSpec::new("pid", Type::Int), + ], vec!["id".to_string()], vec![], vec![], vec![fk("pid", "parent", Some("id"))], false, - Some("create table child (id serial primary key, pid int references parent(id))".to_string()), + Some( + "create table child (id serial primary key, pid int references parent(id))".to_string(), + ), )) .expect("create child with FK"); // A UNIQUE column forces the rebuild path. let mut c = ColumnSpec::new("code", Type::Int); c.unique = true; - r.block_on(db.add_column("child".to_string(), c, Some("add column child: code(int) unique".to_string()))) - .expect("add column via rebuild"); + r.block_on(db.add_column( + "child".to_string(), + c, + Some("add column child: code(int) unique".to_string()), + )) + .expect("add column via rebuild"); // The relationship still exists after the rebuild. - 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"); + let child = r + .block_on(db.describe_table("child".to_string())) + .expect("describe"); + assert_eq!( + child.outbound_relationships.len(), + 1, + "FK survived the column-add rebuild" + ); // And the engine still enforces it (now and after a fresh rebuild). insert_parent_row(&db, &r); - 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"); assert!( r.block_on(db.insert( "child".to_string(), @@ -1259,26 +1502,42 @@ fn fk_referential_actions_survive_rebuild() { let mut spec = fk("pid", "parent", Some("id")); spec.on_delete = ReferentialAction::Cascade; spec.on_update = ReferentialAction::SetNull; - r.block_on(db.sql_create_table( - "child".to_string(), - vec![ColumnSpec::new("id", Type::Serial), ColumnSpec::new("pid", Type::Int)], - vec!["id".to_string()], - vec![], - vec![], - vec![spec], - false, - Some( - "create table child (id serial primary key, pid int references parent(id) \ + r.block_on( + db.sql_create_table( + "child".to_string(), + vec![ + ColumnSpec::new("id", Type::Serial), + ColumnSpec::new("pid", Type::Int), + ], + vec!["id".to_string()], + vec![], + vec![], + vec![spec], + false, + Some( + "create table child (id serial primary key, pid int references parent(id) \ on delete cascade on update set null)" - .to_string(), + .to_string(), + ), ), - )) + ) .expect("create"); - r.block_on(db.rebuild_from_text(p.path().to_path_buf(), None)).expect("rebuild"); - let child = r.block_on(db.describe_table("child".to_string())).expect("describe"); + 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())) + .expect("describe"); let rel = &child.outbound_relationships[0]; - assert_eq!(rel.on_delete, ReferentialAction::Cascade, "ON DELETE survived rebuild"); - assert_eq!(rel.on_update, ReferentialAction::SetNull, "ON UPDATE survived rebuild"); + assert_eq!( + rel.on_delete, + ReferentialAction::Cascade, + "ON DELETE survived rebuild" + ); + assert_eq!( + rel.on_update, + ReferentialAction::SetNull, + "ON UPDATE survived rebuild" + ); } #[test] @@ -1288,19 +1547,29 @@ fn dropping_the_child_clears_the_fk_relationship() { make_parent(&db, &r); r.block_on(db.sql_create_table( "child".to_string(), - vec![ColumnSpec::new("id", Type::Serial), ColumnSpec::new("pid", Type::Int)], + vec![ + ColumnSpec::new("id", Type::Serial), + ColumnSpec::new("pid", Type::Int), + ], vec!["id".to_string()], vec![], vec![], vec![fk("pid", "parent", Some("id"))], false, - Some("create table child (id serial primary key, pid int references parent(id))".to_string()), + Some( + "create table child (id serial primary key, pid int references parent(id))".to_string(), + ), )) .expect("create"); r.block_on(db.drop_table("child".to_string(), Some("drop table child".to_string()))) .expect("drop child"); - let parent = r.block_on(db.describe_table("parent".to_string())).expect("describe parent"); - assert!(parent.inbound_relationships.is_empty(), "dropping the child cleared the relationship"); + 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" + ); } #[test] @@ -1311,17 +1580,23 @@ fn dropping_a_referenced_parent_is_refused() { make_parent(&db, &r); r.block_on(db.sql_create_table( "child".to_string(), - vec![ColumnSpec::new("id", Type::Serial), ColumnSpec::new("pid", Type::Int)], + vec![ + ColumnSpec::new("id", Type::Serial), + ColumnSpec::new("pid", Type::Int), + ], vec!["id".to_string()], vec![], vec![], vec![fk("pid", "parent", Some("id"))], false, - Some("create table child (id serial primary key, pid int references parent(id))".to_string()), + Some( + "create table child (id serial primary key, pid int references parent(id))".to_string(), + ), )) .expect("create"); assert!( - r.block_on(db.drop_table("parent".to_string(), Some("drop table parent".to_string()))).is_err(), + r.block_on(db.drop_table("parent".to_string(), Some("drop table parent".to_string()))) + .is_err(), "a referenced parent can't be dropped while the child's FK exists" ); } @@ -1332,7 +1607,10 @@ fn bare_self_reference_resolves_to_own_pk() { let r = rt(); r.block_on(db.sql_create_table( "emp".to_string(), - vec![ColumnSpec::new("id", Type::Int), ColumnSpec::new("mgr", Type::Int)], + vec![ + ColumnSpec::new("id", Type::Int), + ColumnSpec::new("mgr", Type::Int), + ], vec!["id".to_string()], vec![], vec![], @@ -1341,8 +1619,14 @@ fn bare_self_reference_resolves_to_own_pk() { Some("create table emp (id int primary key, mgr int references emp)".to_string()), )) .expect("create self-referential emp with a bare reference"); - let emp = r.block_on(db.describe_table("emp".to_string())).expect("describe"); - assert_eq!(emp.outbound_relationships[0].other_columns, vec!["id".to_string()], "bare self-ref resolved to own PK"); + let emp = r + .block_on(db.describe_table("emp".to_string())) + .expect("describe"); + assert_eq!( + emp.outbound_relationships[0].other_columns, + vec!["id".to_string()], + "bare self-ref resolved to own PK" + ); // Enforced: a non-existent manager is rejected. r.block_on(db.insert( "emp".to_string(), @@ -1355,7 +1639,10 @@ fn bare_self_reference_resolves_to_own_pk() { r.block_on(db.insert( "emp".to_string(), Some(vec!["id".to_string(), "mgr".to_string()]), - vec![Value::Number("2".to_string()), Value::Number("99".to_string())], + vec![ + Value::Number("2".to_string()), + Value::Number("99".to_string()) + ], Some("insert".to_string()), )) .is_err(), diff --git a/tests/it/sql_delete.rs b/tests/it/sql_delete.rs index 8f95463..7fe4cc9 100644 --- a/tests/it/sql_delete.rs +++ b/tests/it/sql_delete.rs @@ -34,8 +34,7 @@ fn rt() -> tokio::runtime::Runtime { fn open_project_db() -> (project::Project, Database, tempfile::TempDir) { let dir = tempfile::tempdir().expect("create tempdir"); - let project = - project::open_or_create(None, Some(dir.path())).expect("open or create project"); + let project = project::open_or_create(None, Some(dir.path())).expect("open or create project"); let persistence = Persistence::new(project.path().to_path_buf()); let db = Database::open_with_persistence(project.db_path(), persistence) .expect("open db with persistence"); @@ -83,9 +82,11 @@ fn run_delete( input: &str, ) -> Result { match parse_command(input).expect("parse delete") { - Command::SqlDelete { sql, target_table, returning } => { - rt.block_on(db.run_sql_delete(sql, Some(input.to_string()), target_table, returning)) - } + Command::SqlDelete { + sql, + target_table, + returning, + } => rt.block_on(db.run_sql_delete(sql, Some(input.to_string()), target_table, returning)), other => panic!("expected Command::SqlDelete, got {other:?}"), } } @@ -95,8 +96,20 @@ fn run_delete( /// `ON DELETE CASCADE`. Seeds Alice (1) with two orders (10, 11) /// and Bob (2) with one order (12). fn cascade_fixture(db: &Database, rt: &tokio::runtime::Runtime) { - create_cols(db, rt, "Customers", &[("id", Type::Int), ("Name", Type::Text)], &["id"]); - create_cols(db, rt, "Orders", &[("id", Type::Int), ("CustId", Type::Int)], &["id"]); + create_cols( + db, + rt, + "Customers", + &[("id", Type::Int), ("Name", Type::Text)], + &["id"], + ); + create_cols( + db, + rt, + "Orders", + &[("id", Type::Int), ("CustId", Type::Int)], + &["id"], + ); rt.block_on(db.add_relationship( Some("places".to_string()), "Customers".to_string(), @@ -109,16 +122,28 @@ fn cascade_fixture(db: &Database, rt: &tokio::runtime::Runtime) { None, )) .expect("add cascade relationship"); - seed(db, rt, "insert into Customers (id, Name) values (1, 'Alice'), (2, 'Bob')", "Customers"); - seed(db, rt, "insert into Orders (id, CustId) values (10, 1), (11, 1), (12, 2)", "Orders"); + seed( + db, + rt, + "insert into Customers (id, Name) values (1, 'Alice'), (2, 'Bob')", + "Customers", + ); + seed( + db, + rt, + "insert into Orders (id, CustId) values (10, 1), (11, 1), (12, 2)", + "Orders", + ); } #[test] fn parse_path_lowers_sql_delete_to_command() { - let command = parse_command("delete from Orders where id = 1") - .expect("delete parses in advanced mode"); + let command = + parse_command("delete from Orders where id = 1").expect("delete parses in advanced mode"); match command { - Command::SqlDelete { sql, target_table, .. } => { + Command::SqlDelete { + sql, target_table, .. + } => { assert_eq!(sql, "delete from Orders where id = 1"); assert_eq!(target_table, "Orders"); } @@ -130,14 +155,28 @@ fn parse_path_lowers_sql_delete_to_command() { fn delete_with_where_persists() { 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, 'gone'), (2, 'keep')", "t"); + create_cols( + &db, + &rt, + "t", + &[("id", Type::Int), ("v", Type::Text)], + &["id"], + ); + seed( + &db, + &rt, + "insert into t (id, v) values (1, 'gone'), (2, 'keep')", + "t", + ); let result = run_delete(&db, &rt, "delete from t where id = 1").expect("delete runs"); assert_eq!(result.rows_affected, 1, "one row deleted"); assert!(result.cascade.is_empty(), "no children, no cascade"); let csv = read_csv(&project, "t").expect("t.csv"); assert!(csv.contains("keep"), "untouched row preserved: {csv:?}"); - assert!(!csv.contains("gone"), "deleted row removed from CSV: {csv:?}"); + assert!( + !csv.contains("gone"), + "deleted row removed from CSV: {csv:?}" + ); } #[test] @@ -145,18 +184,35 @@ fn delete_without_where_runs_across_all_rows() { // ADR-0030 §12: no `--all-rows` rail. 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, 'a'), (2, 'b'), (3, 'c')", "t"); + create_cols( + &db, + &rt, + "t", + &[("id", Type::Int), ("v", Type::Text)], + &["id"], + ); + seed( + &db, + &rt, + "insert into t (id, v) values (1, 'a'), (2, 'b'), (3, 'c')", + "t", + ); let result = run_delete(&db, &rt, "delete from t").expect("unfiltered delete runs"); assert_eq!(result.rows_affected, 3, "all rows deleted"); // Empty tables produce no CSV (CLAUDE.md persistence note), so the // file is either absent or has only a header — either way, no data. 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 .block_on(db.query_data("t".to_string(), None, None)) .expect("query t"); - assert!(remaining.rows.is_empty(), "table empty after unfiltered delete"); + assert!( + remaining.rows.is_empty(), + "table empty after unfiltered delete" + ); } #[test] @@ -165,8 +221,8 @@ fn cascade_delete_reports_summary_and_repersists_child() { let rt = rt(); cascade_fixture(&db, &rt); // Delete Alice (customer 1) — cascades to her two orders (10, 11). - let result = run_delete(&db, &rt, "delete from Customers where id = 1") - .expect("cascading delete runs"); + let result = + run_delete(&db, &rt, "delete from Customers where id = 1").expect("cascading delete runs"); assert_eq!(result.rows_affected, 1, "one parent row deleted"); assert_eq!(result.cascade.len(), 1, "one cascade relationship reported"); let effect = &result.cascade[0]; @@ -177,9 +233,14 @@ fn cascade_delete_reports_summary_and_repersists_child() { assert_eq!(effect.rows_changed, 2, "both of Alice's orders cascaded"); // The child CSV must be re-persisted to reflect the cascade. let orders_csv = read_csv(&project, "Orders").expect("Orders.csv"); - assert!(orders_csv.contains("12"), "Bob's order (12) preserved: {orders_csv:?}"); - assert!(!orders_csv.contains("10") && !orders_csv.contains("11"), - "Alice's cascaded orders gone from CSV: {orders_csv:?}"); + assert!( + orders_csv.contains("12"), + "Bob's order (12) preserved: {orders_csv:?}" + ); + assert!( + !orders_csv.contains("10") && !orders_csv.contains("11"), + "Alice's cascaded orders gone from CSV: {orders_csv:?}" + ); } #[test] @@ -193,8 +254,8 @@ fn cascade_parity_with_dsl() { let (_p_sql, db_sql, _d_sql) = open_project_db(); cascade_fixture(&db_sql, &rt); - let sql_result = run_delete(&db_sql, &rt, "delete from Customers where id = 1") - .expect("SQL delete runs"); + let sql_result = + run_delete(&db_sql, &rt, "delete from Customers where id = 1").expect("SQL delete runs"); let (_p_dsl, db_dsl, _d_dsl) = open_project_db(); cascade_fixture(&db_dsl, &rt); @@ -206,8 +267,14 @@ fn cascade_parity_with_dsl() { )) .expect("DSL delete runs"); - assert_eq!(sql_result.rows_affected, dsl_result.rows_affected, "row counts match"); - assert_eq!(sql_result.cascade, dsl_result.cascade, "cascade summaries identical"); + assert_eq!( + sql_result.rows_affected, dsl_result.rows_affected, + "row counts match" + ); + assert_eq!( + sql_result.cascade, dsl_result.cascade, + "cascade summaries identical" + ); } #[test] @@ -229,9 +296,14 @@ fn r2_where_with_subquery() { assert_eq!(result.rows_affected, 2, "Alice's two orders deleted"); assert!(result.cascade.is_empty(), "Orders has no cascade children"); let orders_csv = read_csv(&project, "Orders").expect("Orders.csv"); - assert!(orders_csv.contains("12"), "Bob's order preserved: {orders_csv:?}"); - assert!(!orders_csv.contains("10") && !orders_csv.contains("11"), - "Alice's orders deleted: {orders_csv:?}"); + assert!( + orders_csv.contains("12"), + "Bob's order preserved: {orders_csv:?}" + ); + assert!( + !orders_csv.contains("10") && !orders_csv.contains("11"), + "Alice's orders deleted: {orders_csv:?}" + ); } #[test] @@ -255,10 +327,15 @@ fn r2_cascade_with_subquery_where() { .expect("cascade + subquery-WHERE delete runs"); assert_eq!(result.rows_affected, 1, "Alice deleted"); assert_eq!(result.cascade.len(), 1, "one cascade relationship"); - assert_eq!(result.cascade[0].rows_changed, 2, "both Alice orders cascaded"); + assert_eq!( + result.cascade[0].rows_changed, 2, + "both Alice orders cascaded" + ); let orders_csv = read_csv(&project, "Orders").expect("Orders.csv"); - assert!(orders_csv.contains("12") && !orders_csv.contains("10") && !orders_csv.contains("11"), - "only Bob's order remains: {orders_csv:?}"); + assert!( + orders_csv.contains("12") && !orders_csv.contains("10") && !orders_csv.contains("11"), + "only Bob's order remains: {orders_csv:?}" + ); } #[test] @@ -269,9 +346,27 @@ fn cascade_to_two_children_reports_both() { // emitting more than one effect. let (project, db, _dir) = open_project_db(); let rt = rt(); - create_cols(&db, &rt, "Customers", &[("id", Type::Int), ("Name", Type::Text)], &["id"]); - create_cols(&db, &rt, "Orders", &[("id", Type::Int), ("CustId", Type::Int)], &["id"]); - create_cols(&db, &rt, "Reviews", &[("id", Type::Int), ("CustId", Type::Int)], &["id"]); + create_cols( + &db, + &rt, + "Customers", + &[("id", Type::Int), ("Name", Type::Text)], + &["id"], + ); + create_cols( + &db, + &rt, + "Orders", + &[("id", Type::Int), ("CustId", Type::Int)], + &["id"], + ); + create_cols( + &db, + &rt, + "Reviews", + &[("id", Type::Int), ("CustId", Type::Int)], + &["id"], + ); for (child, name) in [("Orders", "places"), ("Reviews", "writes")] { rt.block_on(db.add_relationship( Some(name.to_string()), @@ -286,14 +381,33 @@ fn cascade_to_two_children_reports_both() { )) .unwrap_or_else(|e| panic!("add rel {name}: {e:?}")); } - seed(&db, &rt, "insert into Customers (id, Name) values (1, 'Alice')", "Customers"); - seed(&db, &rt, "insert into Orders (id, CustId) values (10, 1), (11, 1)", "Orders"); - seed(&db, &rt, "insert into Reviews (id, CustId) values (20, 1)", "Reviews"); + seed( + &db, + &rt, + "insert into Customers (id, Name) values (1, 'Alice')", + "Customers", + ); + seed( + &db, + &rt, + "insert into Orders (id, CustId) values (10, 1), (11, 1)", + "Orders", + ); + seed( + &db, + &rt, + "insert into Reviews (id, CustId) values (20, 1)", + "Reviews", + ); let result = run_delete(&db, &rt, "delete from Customers where id = 1") .expect("cascade-to-two delete runs"); assert_eq!(result.rows_affected, 1); - assert_eq!(result.cascade.len(), 2, "both cascade relationships reported"); + assert_eq!( + result.cascade.len(), + 2, + "both cascade relationships reported" + ); let by_child: std::collections::HashMap<&str, i64> = result .cascade .iter() @@ -302,9 +416,16 @@ fn cascade_to_two_children_reports_both() { assert_eq!(by_child.get("Orders"), Some(&2), "two orders cascaded"); assert_eq!(by_child.get("Reviews"), Some(&1), "one review cascaded"); // Both child CSVs re-persisted to the post-cascade (empty) state. - let orders = rt.block_on(db.query_data("Orders".to_string(), None, None)).unwrap(); - let reviews = rt.block_on(db.query_data("Reviews".to_string(), None, None)).unwrap(); - assert!(orders.rows.is_empty() && reviews.rows.is_empty(), "both children emptied"); + let orders = rt + .block_on(db.query_data("Orders".to_string(), None, None)) + .unwrap(); + let reviews = rt + .block_on(db.query_data("Reviews".to_string(), None, None)) + .unwrap(); + assert!( + orders.rows.is_empty() && reviews.rows.is_empty(), + "both children emptied" + ); let _ = &project; } @@ -318,11 +439,19 @@ fn delete_childless_parent_reports_no_cascade() { let rt = rt(); cascade_fixture(&db, &rt); // Carol (3) exists with no orders; deleting her cascades nothing. - seed(&db, &rt, "insert into Customers (id, Name) values (3, 'Carol')", "Customers"); + seed( + &db, + &rt, + "insert into Customers (id, Name) values (3, 'Carol')", + "Customers", + ); let result = run_delete(&db, &rt, "delete from Customers where id = 3") .expect("childless-parent delete runs"); assert_eq!(result.rows_affected, 1, "Carol deleted"); - assert!(result.cascade.is_empty(), "no children → no cascade effect reported"); + assert!( + result.cascade.is_empty(), + "no children → no cascade effect reported" + ); // All three orders untouched. let orders_csv = read_csv(&project, "Orders").expect("Orders.csv"); assert!( @@ -340,8 +469,20 @@ fn delete_violating_fk_fails_and_persists_nothing() { // parent row survives and history records no line. let (project, db, _dir) = open_project_db(); let rt = rt(); - create_cols(&db, &rt, "Customers", &[("id", Type::Int), ("Name", Type::Text)], &["id"]); - create_cols(&db, &rt, "Orders", &[("id", Type::Int), ("CustId", Type::Int)], &["id"]); + create_cols( + &db, + &rt, + "Customers", + &[("id", Type::Int), ("Name", Type::Text)], + &["id"], + ); + create_cols( + &db, + &rt, + "Orders", + &[("id", Type::Int), ("CustId", Type::Int)], + &["id"], + ); rt.block_on(db.add_relationship( Some("places".to_string()), "Customers".to_string(), @@ -354,18 +495,40 @@ fn delete_violating_fk_fails_and_persists_nothing() { None, )) .expect("add NO ACTION relationship"); - seed(&db, &rt, "insert into Customers (id, Name) values (1, 'Alice')", "Customers"); - seed(&db, &rt, "insert into Orders (id, CustId) values (10, 1)", "Orders"); + seed( + &db, + &rt, + "insert into Customers (id, Name) values (1, 'Alice')", + "Customers", + ); + seed( + &db, + &rt, + "insert into Orders (id, CustId) values (10, 1)", + "Orders", + ); let input = "delete from Customers where id = 1"; 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. - 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"); + let customers = rt + .block_on(db.query_data("Customers".to_string(), None, None)) + .unwrap(); + assert_eq!( + customers.rows.len(), + 1, + "parent row preserved after rejected delete" + ); // No history line for the failed statement (written only on success). let history = std::fs::read_to_string(project.path().join("history.log")).unwrap_or_default(); - assert!(!history.contains(input), "failed delete not logged: {history:?}"); + assert!( + !history.contains(input), + "failed delete not logged: {history:?}" + ); } #[test] @@ -378,7 +541,13 @@ fn self_referential_cascade_counts_only_cascaded_rows() { // direct delete). Without the self-ref correction this reports 3. let (_project, db, _dir) = open_project_db(); let rt = rt(); - create_cols(&db, &rt, "T", &[("id", Type::Int), ("ParentId", Type::Int)], &["id"]); + create_cols( + &db, + &rt, + "T", + &[("id", Type::Int), ("ParentId", Type::Int)], + &["id"], + ); rt.block_on(db.add_relationship( Some("parent_of".to_string()), "T".to_string(), @@ -391,11 +560,22 @@ fn self_referential_cascade_counts_only_cascaded_rows() { None, )) .expect("add self-referential relationship"); - seed(&db, &rt, "insert into T (id, ParentId) values (1, null), (2, 1), (3, 2)", "T"); - let result = - run_delete(&db, &rt, "delete from T where id = 1").expect("self-ref delete runs"); - assert_eq!(result.rows_affected, 1, "one row matched the WHERE directly"); - assert_eq!(result.cascade.len(), 1, "self-ref relationship reported once"); + seed( + &db, + &rt, + "insert into T (id, ParentId) values (1, null), (2, 1), (3, 2)", + "T", + ); + let result = run_delete(&db, &rt, "delete from T where id = 1").expect("self-ref delete runs"); + assert_eq!( + result.rows_affected, 1, + "one row matched the WHERE directly" + ); + assert_eq!( + result.cascade.len(), + 1, + "self-ref relationship reported once" + ); assert_eq!( result.cascade[0].rows_changed, 2, "only the 2 cascaded rows, not the directly-deleted root too" @@ -421,8 +601,19 @@ fn internal_target_table_rejected_at_parse() { fn delete_returning_yields_predelete_row() { 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, 'gone'), (2, 'keep')", "t"); + create_cols( + &db, + &rt, + "t", + &[("id", Type::Int), ("v", Type::Text)], + &["id"], + ); + seed( + &db, + &rt, + "insert into t (id, v) values (1, 'gone'), (2, 'keep')", + "t", + ); let result = run_delete(&db, &rt, "delete from t where id = 1 returning *") .expect("DELETE … RETURNING * runs"); assert_eq!(result.rows_affected, 1, "one row deleted"); @@ -431,7 +622,10 @@ fn delete_returning_yields_predelete_row() { assert_eq!(result.data.rows[0][1], Some("gone".to_string())); // And it really is gone from the table. let csv = read_csv(&project, "t").expect("t.csv"); - assert!(!csv.contains("gone") && csv.contains("keep"), "row actually deleted: {csv:?}"); + assert!( + !csv.contains("gone") && csv.contains("keep"), + "row actually deleted: {csv:?}" + ); } #[test] @@ -449,5 +643,8 @@ fn delete_returning_with_cascade_surfaces_both() { // Cascade summary still computed alongside the result set. assert_eq!(result.cascade.len(), 1, "cascade reported"); assert_eq!(result.cascade[0].child_table, "Orders"); - assert_eq!(result.cascade[0].rows_changed, 2, "both Alice's orders cascaded"); + assert_eq!( + result.cascade[0].rows_changed, 2, + "both Alice's orders cascaded" + ); } diff --git a/tests/it/sql_dml_e2e.rs b/tests/it/sql_dml_e2e.rs index d316c08..9cf74fd 100644 --- a/tests/it/sql_dml_e2e.rs +++ b/tests/it/sql_dml_e2e.rs @@ -55,8 +55,7 @@ fn rt() -> tokio::runtime::Runtime { fn open_project_db() -> (project::Project, Database, tempfile::TempDir) { let dir = tempfile::tempdir().expect("create tempdir"); - let project = - project::open_or_create(None, Some(dir.path())).expect("open or create project"); + let project = project::open_or_create(None, Some(dir.path())).expect("open or create project"); let persistence = Persistence::new(project.path().to_path_buf()); let db = Database::open_with_persistence(project.db_path(), persistence) .expect("open db with persistence"); @@ -116,15 +115,18 @@ fn run_update( input: &str, ) -> Result { match parse_command(input).unwrap_or_else(|e| panic!("parse {input:?}: {e:?}")) { - Command::SqlUpdate { sql, target_table, returning, set_literals } => rt.block_on( - db.run_sql_update_with_literals( - sql, - Some(input.to_string()), - target_table, - returning, - set_literals, - ), - ), + Command::SqlUpdate { + sql, + target_table, + returning, + set_literals, + } => rt.block_on(db.run_sql_update_with_literals( + sql, + Some(input.to_string()), + target_table, + returning, + set_literals, + )), other => panic!("expected Command::SqlUpdate from {input:?}, got {other:?}"), } } @@ -135,9 +137,11 @@ fn run_delete( input: &str, ) -> Result { match parse_command(input).unwrap_or_else(|e| panic!("parse {input:?}: {e:?}")) { - Command::SqlDelete { sql, target_table, returning } => rt.block_on( - db.run_sql_delete(sql, Some(input.to_string()), target_table, returning), - ), + Command::SqlDelete { + sql, + target_table, + returning, + } => rt.block_on(db.run_sql_delete(sql, Some(input.to_string()), target_table, returning)), other => panic!("expected Command::SqlDelete from {input:?}, got {other:?}"), } } @@ -162,9 +166,25 @@ fn query(db: &Database, rt: &tokio::runtime::Runtime, table: &str) -> Vec = rows.iter().filter_map(|r| r[9].as_deref()).collect(); assert_eq!(sids.len(), 2, "both shortids present"); - assert!(sids.iter().all(|s| !s.is_empty()), "shortids non-empty: {sids:?}"); - assert_ne!(sids[0], sids[1], "auto-filled shortids are distinct: {sids:?}"); + assert!( + sids.iter().all(|s| !s.is_empty()), + "shortids non-empty: {sids:?}" + ); + assert_ne!( + sids[0], sids[1], + "auto-filled shortids are distinct: {sids:?}" + ); let sers: Vec<&str> = rows.iter().filter_map(|r| r[0].as_deref()).collect(); - assert!(sers.contains(&"1") && sers.contains(&"2"), "serial auto-incremented: {sers:?}"); + assert!( + sers.contains(&"1") && sers.contains(&"2"), + "serial auto-incremented: {sers:?}" + ); } // =============================================================== @@ -275,18 +310,34 @@ fn e2e_update_with_subquery_in_set() { &db, &rt, "customers", - &[("id", Type::Int), ("name", Type::Text), ("last_order", Type::Int)], + &[ + ("id", Type::Int), + ("name", Type::Text), + ("last_order", Type::Int), + ], &["id"], ); create_cols( &db, &rt, "orders", - &[("id", Type::Int), ("cust", Type::Int), ("amount", Type::Int)], + &[ + ("id", Type::Int), + ("cust", Type::Int), + ("amount", Type::Int), + ], &["id"], ); - seed(&db, &rt, "insert into customers (id, name, last_order) values (1, 'A', 0), (2, 'B', 0)"); - seed(&db, &rt, "insert into orders (id, cust, amount) values (10, 1, 50), (11, 1, 30), (12, 2, 99)"); + seed( + &db, + &rt, + "insert into customers (id, name, last_order) values (1, 'A', 0), (2, 'B', 0)", + ); + seed( + &db, + &rt, + "insert into orders (id, cust, amount) values (10, 1, 50), (11, 1, 30), (12, 2, 99)", + ); let result = run_update( &db, @@ -298,13 +349,26 @@ fn e2e_update_with_subquery_in_set() { assert_eq!(result.rows_affected, 2, "both customers updated"); let rows = query(&db, &rt, "customers"); - let c1 = rows.iter().find(|r| r[0].as_deref() == Some("1")).expect("customer 1"); - let c2 = rows.iter().find(|r| r[0].as_deref() == Some("2")).expect("customer 2"); - assert_eq!(c1[2].as_deref(), Some("50"), "customer 1 → max(50, 30) = 50"); + let c1 = rows + .iter() + .find(|r| r[0].as_deref() == Some("1")) + .expect("customer 1"); + let c2 = rows + .iter() + .find(|r| r[0].as_deref() == Some("2")) + .expect("customer 2"); + assert_eq!( + c1[2].as_deref(), + Some("50"), + "customer 1 → max(50, 30) = 50" + ); assert_eq!(c2[2].as_deref(), Some("99"), "customer 2 → max(99) = 99"); let csv = read_csv(&project, "customers").expect("customers.csv"); - assert!(csv.contains("50") && csv.contains("99"), "CSV reflects the update: {csv:?}"); + assert!( + csv.contains("50") && csv.contains("99"), + "CSV reflects the update: {csv:?}" + ); } // =============================================================== @@ -313,8 +377,20 @@ fn e2e_update_with_subquery_in_set() { // =============================================================== fn cascade_fixture(db: &Database, rt: &tokio::runtime::Runtime) { - create_cols(db, rt, "Customers", &[("id", Type::Int), ("Name", Type::Text)], &["id"]); - create_cols(db, rt, "Orders", &[("id", Type::Int), ("CustId", Type::Int)], &["id"]); + create_cols( + db, + rt, + "Customers", + &[("id", Type::Int), ("Name", Type::Text)], + &["id"], + ); + create_cols( + db, + rt, + "Orders", + &[("id", Type::Int), ("CustId", Type::Int)], + &["id"], + ); rt.block_on(db.add_relationship( Some("places".to_string()), "Customers".to_string(), @@ -327,8 +403,16 @@ fn cascade_fixture(db: &Database, rt: &tokio::runtime::Runtime) { None, )) .expect("add cascade relationship"); - seed(db, rt, "insert into Customers (id, Name) values (1, 'Alice'), (2, 'Bob')"); - seed(db, rt, "insert into Orders (id, CustId) values (10, 1), (11, 1), (12, 2)"); + seed( + db, + rt, + "insert into Customers (id, Name) values (1, 'Alice'), (2, 'Bob')", + ); + seed( + db, + rt, + "insert into Orders (id, CustId) values (10, 1), (11, 1), (12, 2)", + ); } #[test] @@ -337,8 +421,8 @@ fn e2e_delete_with_cascade_reports_summary_and_repersists_children() { let rt = rt(); cascade_fixture(&db, &rt); - let result = run_delete(&db, &rt, "delete from Customers where id = 1") - .expect("cascading DELETE runs"); + let result = + run_delete(&db, &rt, "delete from Customers where id = 1").expect("cascading DELETE runs"); assert_eq!(result.rows_affected, 1, "one parent row deleted"); assert_eq!(result.cascade.len(), 1, "one cascade relationship affected"); let effect = &result.cascade[0]; @@ -346,9 +430,18 @@ fn e2e_delete_with_cascade_reports_summary_and_repersists_children() { assert_eq!(effect.rows_changed, 2, "Alice's two orders cascaded"); let orders_csv = read_csv(&project, "Orders").expect("Orders.csv re-persisted"); - assert!(orders_csv.contains("12"), "Bob's order (12) preserved: {orders_csv:?}"); - assert!(!orders_csv.contains("10"), "Alice's order 10 cascaded away: {orders_csv:?}"); - assert!(!orders_csv.contains("11"), "Alice's order 11 cascaded away: {orders_csv:?}"); + assert!( + orders_csv.contains("12"), + "Bob's order (12) preserved: {orders_csv:?}" + ); + assert!( + !orders_csv.contains("10"), + "Alice's order 10 cascaded away: {orders_csv:?}" + ); + assert!( + !orders_csv.contains("11"), + "Alice's order 11 cascaded away: {orders_csv:?}" + ); } // =============================================================== @@ -359,7 +452,13 @@ fn e2e_delete_with_cascade_reports_summary_and_repersists_children() { fn e2e_upsert_round_trip_do_update_then_do_nothing() { let (project, db, _dir) = open_project_db(); let rt = rt(); - create_cols(&db, &rt, "kv", &[("id", Type::Int), ("name", Type::Text)], &["id"]); + create_cols( + &db, + &rt, + "kv", + &[("id", Type::Int), ("name", Type::Text)], + &["id"], + ); seed(&db, &rt, "insert into kv (id, name) values (1, 'old')"); // DO UPDATE on a conflict mutates the existing row. @@ -369,9 +468,15 @@ fn e2e_upsert_round_trip_do_update_then_do_nothing() { "insert into kv (id, name) values (1, 'new') on conflict (id) do update set name = excluded.name", ) .expect("UPSERT DO UPDATE runs"); - assert_eq!(upd.rows_affected, 1, "DO UPDATE touches the conflicting row"); + assert_eq!( + upd.rows_affected, 1, + "DO UPDATE touches the conflicting row" + ); let csv = read_csv(&project, "kv").expect("kv.csv"); - assert!(csv.contains("new") && !csv.contains("old"), "row updated to 'new': {csv:?}"); + assert!( + csv.contains("new") && !csv.contains("old"), + "row updated to 'new': {csv:?}" + ); // DO NOTHING on a conflict is a no-op. let nothing = run_insert( @@ -382,7 +487,10 @@ fn e2e_upsert_round_trip_do_update_then_do_nothing() { .expect("UPSERT DO NOTHING runs"); assert_eq!(nothing.rows_affected, 0, "DO NOTHING changes no rows"); let csv = read_csv(&project, "kv").expect("kv.csv"); - assert!(csv.contains("new") && !csv.contains("ignored"), "row unchanged by DO NOTHING: {csv:?}"); + assert!( + csv.contains("new") && !csv.contains("ignored"), + "row unchanged by DO NOTHING: {csv:?}" + ); } // =============================================================== @@ -393,23 +501,52 @@ fn e2e_upsert_round_trip_do_update_then_do_nothing() { fn e2e_returning_on_insert_update_delete() { let (_project, db, _dir) = open_project_db(); let rt = rt(); - create_cols(&db, &rt, "t", &[("id", Type::Int), ("v", Type::Text)], &["id"]); + create_cols( + &db, + &rt, + "t", + &[("id", Type::Int), ("v", Type::Text)], + &["id"], + ); - let ins = run_insert(&db, &rt, "insert into t (id, v) values (1, 'a') returning id, v") - .expect("INSERT … RETURNING runs"); - assert_eq!(ins.data.rows.len(), 1, "INSERT RETURNING yields the inserted row"); + let ins = run_insert( + &db, + &rt, + "insert into t (id, v) values (1, 'a') returning id, v", + ) + .expect("INSERT … RETURNING runs"); + assert_eq!( + ins.data.rows.len(), + 1, + "INSERT RETURNING yields the inserted row" + ); assert_eq!(ins.data.rows[0][1].as_deref(), Some("a")); let upd = run_update(&db, &rt, "update t set v = 'b' where id = 1 returning v") .expect("UPDATE … RETURNING runs"); - assert_eq!(upd.data.rows.len(), 1, "UPDATE RETURNING yields the modified row"); + assert_eq!( + upd.data.rows.len(), + 1, + "UPDATE RETURNING yields the modified row" + ); assert_eq!(upd.data.rows[0][0].as_deref(), Some("b")); let del = run_delete(&db, &rt, "delete from t where id = 1 returning *") .expect("DELETE … RETURNING runs"); - assert_eq!(del.data.rows.len(), 1, "DELETE RETURNING yields the pre-delete row"); - assert_eq!(del.data.rows[0][1].as_deref(), Some("b"), "pre-delete value surfaced"); - assert!(query(&db, &rt, "t").is_empty(), "row is gone after the DELETE"); + assert_eq!( + del.data.rows.len(), + 1, + "DELETE RETURNING yields the pre-delete row" + ); + assert_eq!( + del.data.rows[0][1].as_deref(), + Some("b"), + "pre-delete value surfaced" + ); + assert!( + query(&db, &rt, "t").is_empty(), + "row is gone after the DELETE" + ); } // =============================================================== @@ -463,7 +600,7 @@ fn e2e_replay_phase3_dml_forms_from_a_script() { ("1".to_string(), Some("a".to_string())), ("11".to_string(), Some("a".to_string())), // INSERT…SELECT id+10 ("2".to_string(), Some("z".to_string())), // UPDATE - // id 3 was DELETEd + // id 3 was DELETEd ] .into_iter() .collect::>() @@ -481,15 +618,42 @@ fn e2e_replay_phase3_dml_forms_from_a_script() { fn e2e_out_of_scope_dml_forms_parse_reject() { let cases = [ ("OOS-1 DEFAULT VALUES", "insert into t default values"), - ("OOS-2 INSERT OR REPLACE", "insert or replace into t values (1)"), - ("OOS-2 INSERT OR IGNORE", "insert or ignore into t values (1)"), - ("OOS-3 UPDATE … FROM", "update t set a = b.x from other b where t.id = b.id"), - ("OOS-4 WITH … UPDATE", "with x as (select 1) update t set a = 1 where id = 1"), - ("OOS-4 WITH … DELETE", "with x as (select 1) delete from t where id = 1"), - ("OOS-5 INDEXED BY", "delete from t indexed by idx where id = 1"), - ("OOS-5 NOT INDEXED", "update t not indexed set a = 1 where id = 1"), - ("OOS-6 multi-statement (DELETE; DELETE)", "delete from t where id = 1; delete from t where id = 2"), - ("OOS-6 multi-statement (INSERT; INSERT)", "insert into t values (1); insert into t values (2)"), + ( + "OOS-2 INSERT OR REPLACE", + "insert or replace into t values (1)", + ), + ( + "OOS-2 INSERT OR IGNORE", + "insert or ignore into t values (1)", + ), + ( + "OOS-3 UPDATE … FROM", + "update t set a = b.x from other b where t.id = b.id", + ), + ( + "OOS-4 WITH … UPDATE", + "with x as (select 1) update t set a = 1 where id = 1", + ), + ( + "OOS-4 WITH … DELETE", + "with x as (select 1) delete from t where id = 1", + ), + ( + "OOS-5 INDEXED BY", + "delete from t indexed by idx where id = 1", + ), + ( + "OOS-5 NOT INDEXED", + "update t not indexed set a = 1 where id = 1", + ), + ( + "OOS-6 multi-statement (DELETE; DELETE)", + "delete from t where id = 1; delete from t where id = 2", + ), + ( + "OOS-6 multi-statement (INSERT; INSERT)", + "insert into t values (1); insert into t values (2)", + ), ]; for (label, src) in cases { assert!( @@ -525,7 +689,10 @@ fn e2e_update_all_rows_in_advanced_falls_back_to_dsl() { assert!( matches!( parse_command_in_mode("update Orders set total = 42 --all-rows", Mode::Advanced), - Ok(Command::Update { filter: RowFilter::AllRows, .. }) + Ok(Command::Update { + filter: RowFilter::AllRows, + .. + }) ), "advanced `update … --all-rows` falls back to the DSL Update", ); @@ -599,5 +766,8 @@ fn e2e_validity_indicator_fires_for_sql_dml_diagnostic() { // And the indicator renders the `[WRN]` label. app.input_indicator = app.input_validity_verdict(); let text = rendered_text(&mut app, &Theme::dark(), 80, 24); - assert!(text.contains("[WRN]"), "the SQL DML warning surfaces as [WRN]:\n{text}"); + assert!( + text.contains("[WRN]"), + "the SQL DML warning surfaces as [WRN]:\n{text}" + ); } diff --git a/tests/it/sql_drop_index.rs b/tests/it/sql_drop_index.rs index 6ae5127..dcfb74e 100644 --- a/tests/it/sql_drop_index.rs +++ b/tests/it/sql_drop_index.rs @@ -21,8 +21,7 @@ fn rt() -> tokio::runtime::Runtime { fn open(undo: bool) -> (project::Project, Database, tempfile::TempDir) { let dir = tempfile::tempdir().expect("create tempdir"); - let project = - project::open_or_create(None, Some(dir.path())).expect("open or create project"); + let project = project::open_or_create(None, Some(dir.path())).expect("open or create project"); let persistence = Persistence::new(project.path().to_path_buf()); let db = Database::open_with_persistence_and_undo(project.db_path(), persistence, undo) .expect("open db with persistence"); @@ -33,7 +32,10 @@ fn open(undo: bool) -> (project::Project, Database, tempfile::TempDir) { fn make_t_with_index(db: &Database, r: &tokio::runtime::Runtime) -> String { r.block_on(db.sql_create_table( "T".to_string(), - vec![ColumnSpec::new("id", Type::Int), ColumnSpec::new("email", Type::Text)], + vec![ + ColumnSpec::new("id", Type::Int), + ColumnSpec::new("email", Type::Text), + ], vec!["id".to_string()], vec![], vec![], @@ -75,7 +77,10 @@ fn drop_index_removes_an_existing_index_and_shows_the_table() { match out { DropIndexOutcome::Dropped(desc) => { assert_eq!(desc.name, "T"); - assert!(desc.indexes.is_empty(), "the index is gone from the structure"); + assert!( + desc.indexes.is_empty(), + "the index is gone from the structure" + ); } DropIndexOutcome::Skipped => panic!("expected Dropped, got Skipped"), } @@ -105,7 +110,10 @@ fn plain_drop_of_an_absent_index_errors() { false, Some("drop index ghost_idx".to_string()), )); - assert!(res.is_err(), "plain DROP INDEX on an absent index errors (no IF EXISTS)"); + assert!( + res.is_err(), + "plain DROP INDEX on an absent index errors (no IF EXISTS)" + ); } #[test] @@ -113,11 +121,18 @@ fn drop_index_is_one_undo_step_and_restores_the_index() { let (_p, db, _d) = open(true); // undo enabled let r = rt(); let name = make_t_with_index(&db, &r); - r.block_on(db.sql_drop_index(name.clone(), false, Some("drop index T_email_idx".to_string()))) - .expect("drop index"); + r.block_on(db.sql_drop_index( + name.clone(), + false, + Some("drop index T_email_idx".to_string()), + )) + .expect("drop index"); assert!(index_names(&db, &r).is_empty()); // One undo brings the index back. - 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_eq!(index_names(&db, &r), vec![name], "undo restored the index"); } diff --git a/tests/it/sql_drop_table.rs b/tests/it/sql_drop_table.rs index 5ea6550..69c323d 100644 --- a/tests/it/sql_drop_table.rs +++ b/tests/it/sql_drop_table.rs @@ -22,8 +22,7 @@ fn rt() -> tokio::runtime::Runtime { fn open(undo: bool) -> (project::Project, Database, tempfile::TempDir) { let dir = tempfile::tempdir().expect("create tempdir"); - let project = - project::open_or_create(None, Some(dir.path())).expect("open or create project"); + let project = project::open_or_create(None, Some(dir.path())).expect("open or create project"); let persistence = Persistence::new(project.path().to_path_buf()); let db = Database::open_with_persistence_and_undo(project.db_path(), persistence, undo) .expect("open db with persistence"); @@ -34,7 +33,10 @@ fn open(undo: bool) -> (project::Project, Database, tempfile::TempDir) { fn make_t(db: &Database, r: &tokio::runtime::Runtime) { r.block_on(db.sql_create_table( "T".to_string(), - vec![ColumnSpec::new("id", Type::Int), ColumnSpec::new("body", Type::Text)], + vec![ + ColumnSpec::new("id", Type::Int), + ColumnSpec::new("body", Type::Text), + ], vec!["id".to_string()], vec![], vec![], @@ -54,7 +56,11 @@ fn drop_table_removes_an_existing_table() { .block_on(db.sql_drop_table("T".to_string(), false, Some("drop table T".to_string()))) .expect("drop"); assert!(matches!(out, DropOutcome::Dropped)); - assert!(!r.block_on(db.list_tables()).unwrap().contains(&"T".to_string())); + assert!( + !r.block_on(db.list_tables()) + .unwrap() + .contains(&"T".to_string()) + ); } #[test] @@ -75,8 +81,15 @@ fn if_exists_on_an_absent_table_is_a_noop_and_journalled() { fn plain_drop_of_an_absent_table_errors() { let (_p, db, _d) = open(false); let r = rt(); - let res = r.block_on(db.sql_drop_table("Ghost".to_string(), false, Some("drop table Ghost".to_string()))); - assert!(res.is_err(), "plain DROP TABLE on an absent table errors (no IF EXISTS)"); + let res = r.block_on(db.sql_drop_table( + "Ghost".to_string(), + false, + Some("drop table Ghost".to_string()), + )); + assert!( + res.is_err(), + "plain DROP TABLE on an absent table errors (no IF EXISTS)" + ); } #[test] @@ -87,7 +100,10 @@ fn dropping_a_referenced_parent_is_refused() { let r = rt(); r.block_on(db.sql_create_table( "parent".to_string(), - vec![ColumnSpec::new("id", Type::Serial), ColumnSpec::new("label", Type::Text)], + vec![ + ColumnSpec::new("id", Type::Serial), + ColumnSpec::new("label", Type::Text), + ], vec!["id".to_string()], vec![], vec![], @@ -98,7 +114,10 @@ fn dropping_a_referenced_parent_is_refused() { .expect("create parent"); r.block_on(db.sql_create_table( "child".to_string(), - vec![ColumnSpec::new("id", Type::Serial), ColumnSpec::new("pid", Type::Int)], + vec![ + ColumnSpec::new("id", Type::Serial), + ColumnSpec::new("pid", Type::Int), + ], vec!["id".to_string()], vec![], vec![], @@ -112,22 +131,36 @@ fn dropping_a_referenced_parent_is_refused() { inline: true, }], false, - Some("create table child (id serial primary key, pid int references parent(id))".to_string()), + Some( + "create table child (id serial primary key, pid int references parent(id))".to_string(), + ), )) .expect("create child with FK"); // The parent is referenced — refused (even with IF EXISTS, since the // table *does* exist; the refusal is about the relationship). assert!( - r.block_on(db.sql_drop_table("parent".to_string(), false, Some("drop table parent".to_string()))) - .is_err(), + r.block_on(db.sql_drop_table( + "parent".to_string(), + false, + Some("drop table parent".to_string()) + )) + .is_err(), "a referenced parent can't be dropped" ); // Dropping the child first succeeds, then the parent. - r.block_on(db.sql_drop_table("child".to_string(), false, Some("drop table child".to_string()))) - .expect("drop child"); - r.block_on(db.sql_drop_table("parent".to_string(), false, Some("drop table parent".to_string()))) - .expect("now the parent drops"); + r.block_on(db.sql_drop_table( + "child".to_string(), + false, + Some("drop table child".to_string()), + )) + .expect("drop child"); + r.block_on(db.sql_drop_table( + "parent".to_string(), + false, + Some("drop table parent".to_string()), + )) + .expect("now the parent drops"); } #[test] @@ -138,17 +171,31 @@ fn drop_table_is_one_undo_step_and_restores_data() { r.block_on(db.insert( "T".to_string(), Some(vec!["id".to_string(), "body".to_string()]), - vec![Value::Number("1".to_string()), Value::Text("hi".to_string())], + vec![ + Value::Number("1".to_string()), + Value::Text("hi".to_string()), + ], Some("insert".to_string()), )) .expect("row"); r.block_on(db.sql_drop_table("T".to_string(), false, Some("drop table T".to_string()))) .expect("drop"); - assert!(!r.block_on(db.list_tables()).unwrap().contains(&"T".to_string())); + assert!( + !r.block_on(db.list_tables()) + .unwrap() + .contains(&"T".to_string()) + ); // One undo brings the table — and its row — back. - 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.undo()).expect("undo").is_some(), + "the drop was one undo step" + ); + assert!( + r.block_on(db.list_tables()) + .unwrap() + .contains(&"T".to_string()) + ); let data = r .block_on(db.query_data("T".to_string(), None, None)) .expect("query"); diff --git a/tests/it/sql_insert.rs b/tests/it/sql_insert.rs index fdd71ec..5d75ed8 100644 --- a/tests/it/sql_insert.rs +++ b/tests/it/sql_insert.rs @@ -41,8 +41,7 @@ fn rt() -> tokio::runtime::Runtime { fn open_project_db() -> (project::Project, Database, tempfile::TempDir) { let dir = tempfile::tempdir().expect("create tempdir"); - let project = - project::open_or_create(None, Some(dir.path())).expect("open or create project"); + let project = project::open_or_create(None, Some(dir.path())).expect("open or create project"); let persistence = Persistence::new(project.path().to_path_buf()); let db = Database::open_with_persistence(project.db_path(), persistence) .expect("open db with persistence"); @@ -86,7 +85,10 @@ fn single_row_insert_persists_and_counts() { .expect("insert runs"); assert_eq!(result.rows_affected, 1, "one row inserted"); let csv = read_csv(&project, "T").expect("T.csv written after insert"); - assert!(csv.contains("Ada"), "CSV reflects the inserted row: {csv:?}"); + assert!( + csv.contains("Ada"), + "CSV reflects the inserted row: {csv:?}" + ); } #[test] @@ -193,7 +195,10 @@ fn failed_multi_row_insert_is_atomic() { String::new(), false, )); - assert!(outcome.is_err(), "multi-row PK conflict must fail: {outcome:?}"); + assert!( + outcome.is_err(), + "multi-row PK conflict must fail: {outcome:?}" + ); let csv = read_csv(&project, "T").expect("T.csv still present"); assert!( csv.contains("existing") && !csv.contains("fresh") && !csv.contains("collides"), @@ -208,7 +213,9 @@ fn parse_path_lowers_sqlinsert_scaffold_to_command() { let command = parse_command("insert into Orders (id, total) values (1, 99.5)") .expect("insert parses in advanced mode"); match command { - Command::SqlInsert { sql, target_table, .. } => { + Command::SqlInsert { + sql, target_table, .. + } => { assert_eq!(sql, "insert into Orders (id, total) values (1, 99.5)"); assert_eq!(target_table, "Orders"); } @@ -248,7 +255,9 @@ fn parse_path_lowers_insert_select_to_command() { let command = parse_command("insert into archive select * from source") .expect("INSERT … SELECT parses in advanced mode"); match command { - Command::SqlInsert { sql, target_table, .. } => { + Command::SqlInsert { + sql, target_table, .. + } => { assert_eq!(sql, "insert into archive select * from source"); assert_eq!(target_table, "archive"); } @@ -259,12 +268,13 @@ fn parse_path_lowers_insert_select_to_command() { #[test] fn parse_path_lowers_with_prefixed_insert_select() { // R4: a WITH-prefixed SELECT row source lowers verbatim. - let command = parse_command( - "insert into archive with t as (select * from orders) select * from t", - ) - .expect("WITH-prefixed INSERT … SELECT parses"); + let command = + parse_command("insert into archive with t as (select * from orders) select * from t") + .expect("WITH-prefixed INSERT … SELECT parses"); match command { - Command::SqlInsert { sql, target_table, .. } => { + Command::SqlInsert { + sql, target_table, .. + } => { assert_eq!( sql, "insert into archive with t as (select * from orders) select * from t", @@ -472,7 +482,13 @@ fn csv_rows(project: &project::Project, table: &str) -> Vec> { fn values_autofills_omitted_shortid_pk() { let (project, db, _dir) = open_project_db(); let rt = rt(); - create_cols(&db, &rt, "t", &[("id", Type::ShortId), ("label", Type::Text)], &["id"]); + create_cols( + &db, + &rt, + "t", + &[("id", Type::ShortId), ("label", Type::Text)], + &["id"], + ); let result = run_sqlinsert(&db, &rt, "insert into t (label) values ('x')") .expect("auto-fill insert runs"); assert_eq!(result.rows_affected, 1); @@ -486,25 +502,36 @@ fn values_autofills_omitted_shortid_pk() { fn values_multirow_autofills_distinct_shortids() { let (project, db, _dir) = open_project_db(); let rt = rt(); - create_cols(&db, &rt, "t", &[("id", Type::ShortId), ("label", Type::Text)], &["id"]); - let result = run_sqlinsert( + create_cols( &db, &rt, - "insert into t (label) values ('a'), ('b'), ('c')", - ) - .expect("multi-row auto-fill runs"); + "t", + &[("id", Type::ShortId), ("label", Type::Text)], + &["id"], + ); + let result = run_sqlinsert(&db, &rt, "insert into t (label) values ('a'), ('b'), ('c')") + .expect("multi-row auto-fill runs"); assert_eq!(result.rows_affected, 3); let rows = csv_rows(&project, "t"); let ids: std::collections::HashSet<&String> = rows.iter().map(|r| &r[0]).collect(); assert_eq!(ids.len(), 3, "three DISTINCT non-empty shortids: {rows:?}"); - assert!(rows.iter().all(|r| !r[0].is_empty()), "no empty id: {rows:?}"); + assert!( + rows.iter().all(|r| !r[0].is_empty()), + "no empty id: {rows:?}" + ); } #[test] fn explicit_shortid_value_is_respected() { let (project, db, _dir) = open_project_db(); let rt = rt(); - create_cols(&db, &rt, "t", &[("id", Type::ShortId), ("label", Type::Text)], &["id"]); + create_cols( + &db, + &rt, + "t", + &[("id", Type::ShortId), ("label", Type::Text)], + &["id"], + ); // The user provided `id` explicitly — it must be honoured // verbatim (the override WARNING is sub-phase 3i). run_sqlinsert( @@ -521,10 +548,21 @@ fn explicit_shortid_value_is_respected() { fn insert_select_autofills_distinct_shortids() { let (project, db, _dir) = open_project_db(); let rt = rt(); - create_cols(&db, &rt, "source", &[("id", Type::ShortId), ("label", Type::Text)], &["id"]); - create_cols(&db, &rt, "target", &[("id", Type::ShortId), ("label", Type::Text)], &["id"]); - run_sqlinsert(&db, &rt, "insert into source (label) values ('a'), ('b')") - .expect("seed source"); + create_cols( + &db, + &rt, + "source", + &[("id", Type::ShortId), ("label", Type::Text)], + &["id"], + ); + create_cols( + &db, + &rt, + "target", + &[("id", Type::ShortId), ("label", Type::Text)], + &["id"], + ); + run_sqlinsert(&db, &rt, "insert into source (label) values ('a'), ('b')").expect("seed source"); let result = run_sqlinsert( &db, &rt, @@ -535,7 +573,10 @@ fn insert_select_autofills_distinct_shortids() { let rows = csv_rows(&project, "target"); let ids: std::collections::HashSet<&String> = rows.iter().map(|r| &r[0]).collect(); assert_eq!(ids.len(), 2, "two DISTINCT fresh shortids: {rows:?}"); - assert!(rows.iter().all(|r| !r[0].is_empty()), "no empty id: {rows:?}"); + assert!( + rows.iter().all(|r| !r[0].is_empty()), + "no empty id: {rows:?}" + ); } #[test] @@ -547,11 +588,14 @@ fn combined_serial_and_shortid_autofill() { &db, &rt, "t", - &[("id", Type::Serial), ("code", Type::ShortId), ("name", Type::Text)], + &[ + ("id", Type::Serial), + ("code", Type::ShortId), + ("name", Type::Text), + ], &["id"], ); - run_sqlinsert(&db, &rt, "insert into t (name) values ('x')") - .expect("combined auto-fill runs"); + run_sqlinsert(&db, &rt, "insert into t (name) values ('x')").expect("combined auto-fill runs"); let rows = csv_rows(&project, "t"); assert_eq!(rows.len(), 1, "{rows:?}"); assert_eq!(rows[0][0], "1", "serial PK engine-filled: {rows:?}"); @@ -566,7 +610,13 @@ fn shortid_autofill_respects_mixed_case_column_name() { // schema name `MyId`, not a lowercased form. let (project, db, _dir) = open_project_db(); let rt = rt(); - create_cols(&db, &rt, "t", &[("MyId", Type::ShortId), ("label", Type::Text)], &["MyId"]); + create_cols( + &db, + &rt, + "t", + &[("MyId", Type::ShortId), ("label", Type::Text)], + &["MyId"], + ); run_sqlinsert(&db, &rt, "insert into t (label) values ('x')") .expect("mixed-case shortid auto-fill runs"); let rows = csv_rows(&project, "t"); @@ -584,7 +634,11 @@ fn two_shortids_pk_and_nonpk_both_autofill_distinctly() { &db, &rt, "t", - &[("id", Type::ShortId), ("code", Type::ShortId), ("label", Type::Text)], + &[ + ("id", Type::ShortId), + ("code", Type::ShortId), + ("label", Type::Text), + ], &["id"], ); let result = run_sqlinsert(&db, &rt, "insert into t (label) values ('x'), ('y')") @@ -611,7 +665,11 @@ fn two_shortids_one_provided_one_autofilled() { &db, &rt, "t", - &[("id", Type::ShortId), ("code", Type::ShortId), ("label", Type::Text)], + &[ + ("id", Type::ShortId), + ("code", Type::ShortId), + ("label", Type::Text), + ], &["id"], ); run_sqlinsert(&db, &rt, "insert into t (id, label) values ('myid', 'x')") @@ -631,7 +689,11 @@ fn compound_pk_with_shortid_member_autofills() { &db, &rt, "t", - &[("id", Type::ShortId), ("region", Type::Int), ("label", Type::Text)], + &[ + ("id", Type::ShortId), + ("region", Type::Int), + ("label", Type::Text), + ], &["id", "region"], ); run_sqlinsert(&db, &rt, "insert into t (region, label) values (1, 'x')") @@ -653,14 +715,23 @@ fn autofill_does_not_mask_arity_mismatch() { // engine rather than mask the error.) let (project, db, _dir) = open_project_db(); let rt = rt(); - create_cols(&db, &rt, "t", &[("id", Type::ShortId), ("label", Type::Text)], &["id"]); + create_cols( + &db, + &rt, + "t", + &[("id", Type::ShortId), ("label", Type::Text)], + &["id"], + ); let outcome = run_sqlinsert(&db, &rt, "insert into t (label) values ('a', 'b')"); assert!( outcome.is_err(), "arity mismatch must be rejected, not masked: {outcome:?}", ); let rows = csv_rows(&project, "t"); - assert!(rows.is_empty(), "no row should land on a rejected insert: {rows:?}"); + assert!( + rows.is_empty(), + "no row should land on a rejected insert: {rows:?}" + ); } #[test] @@ -672,14 +743,19 @@ fn sql_insert_autofills_omitted_nonpk_serial() { // shortid auto-fill, which already runs on this path. let (project, db, _dir) = open_project_db(); let rt = rt(); - create_cols(&db, &rt, "t", &[("id", Type::Int), ("seq", Type::Serial)], &["id"]); + create_cols( + &db, + &rt, + "t", + &[("id", Type::Int), ("seq", Type::Serial)], + &["id"], + ); // Single row, omitting the non-PK serial `seq`. run_sqlinsert(&db, &rt, "insert into t (id) values (10)").expect("single-row insert runs"); // Multi-row, omitting `seq` — each row gets a distinct, increasing // serial continuing from the current MAX. - run_sqlinsert(&db, &rt, "insert into t (id) values (20), (30)") - .expect("multi-row insert runs"); + run_sqlinsert(&db, &rt, "insert into t (id) values (20), (30)").expect("multi-row insert runs"); let rows = csv_rows(&project, "t"); // No NULL serials, and the sequence is 1, 2, 3 across the three rows. @@ -700,11 +776,26 @@ fn autofill_insert_select_wider_projection_is_rejected() { // to the engine instead of dropping the extra projection. let (project, db, _dir) = open_project_db(); let rt = rt(); - create_cols(&db, &rt, "src", &[("a", Type::Text), ("b", Type::Text)], &["a"]); - create_cols(&db, &rt, "t", &[("id", Type::ShortId), ("label", Type::Text)], &["id"]); + create_cols( + &db, + &rt, + "src", + &[("a", Type::Text), ("b", Type::Text)], + &["a"], + ); + create_cols( + &db, + &rt, + "t", + &[("id", Type::ShortId), ("label", Type::Text)], + &["id"], + ); run_sqlinsert(&db, &rt, "insert into src (a, b) values ('p', 'q')").expect("seed"); let outcome = run_sqlinsert(&db, &rt, "insert into t (label) select a, b from src"); - assert!(outcome.is_err(), "wider projection must be rejected: {outcome:?}"); + assert!( + outcome.is_err(), + "wider projection must be rejected: {outcome:?}" + ); assert!(csv_rows(&project, "t").is_empty(), "nothing should land"); } @@ -724,7 +815,10 @@ fn autofill_insert_select_narrower_projection_is_rejected() { ); run_sqlinsert(&db, &rt, "insert into src (a) values ('p')").expect("seed"); let outcome = run_sqlinsert(&db, &rt, "insert into t (x, y) select a from src"); - assert!(outcome.is_err(), "narrower projection must be rejected: {outcome:?}"); + assert!( + outcome.is_err(), + "narrower projection must be rejected: {outcome:?}" + ); assert!(csv_rows(&project, "t").is_empty(), "nothing should land"); } @@ -736,11 +830,25 @@ fn autofill_insert_select_narrower_projection_is_rejected() { fn insert_returning_star_returns_inserted_row() { let (_project, db, _dir) = open_project_db(); let rt = rt(); - create_cols(&db, &rt, "t", &[("id", Type::Int), ("b", Type::Text)], &["id"]); - let result = run_sqlinsert(&db, &rt, "insert into t (id, b) values (1, 'Ada') returning *") - .expect("INSERT … RETURNING * runs"); + create_cols( + &db, + &rt, + "t", + &[("id", Type::Int), ("b", Type::Text)], + &["id"], + ); + let result = run_sqlinsert( + &db, + &rt, + "insert into t (id, b) values (1, 'Ada') returning *", + ) + .expect("INSERT … RETURNING * runs"); assert_eq!(result.rows_affected, 1, "one row inserted"); - assert_eq!(result.data.rows.len(), 1, "RETURNING yielded the inserted row"); + assert_eq!( + result.data.rows.len(), + 1, + "RETURNING yielded the inserted row" + ); assert_eq!(result.data.columns, vec!["id".to_string(), "b".to_string()]); assert_eq!(result.data.rows[0][1], Some("Ada".to_string())); } @@ -749,7 +857,13 @@ fn insert_returning_star_returns_inserted_row() { fn insert_multirow_returning_id_yields_distinct_rows() { let (_project, db, _dir) = open_project_db(); let rt = rt(); - create_cols(&db, &rt, "t", &[("id", Type::Int), ("b", Type::Text)], &["id"]); + create_cols( + &db, + &rt, + "t", + &[("id", Type::Int), ("b", Type::Text)], + &["id"], + ); let result = run_sqlinsert( &db, &rt, @@ -760,7 +874,12 @@ fn insert_multirow_returning_id_yields_distinct_rows() { assert_eq!(result.data.columns, vec!["id".to_string()]); let ids: std::collections::BTreeSet<_> = result.data.rows.iter().map(|r| r[0].clone()).collect(); - assert_eq!(ids.len(), 3, "three distinct ids returned: {:?}", result.data.rows); + assert_eq!( + ids.len(), + 3, + "three distinct ids returned: {:?}", + result.data.rows + ); } #[test] @@ -771,16 +890,33 @@ fn insert_returning_autofills_shortid_and_returns_it() { // surfaces in the returned row. let (_project, db, _dir) = open_project_db(); let rt = rt(); - create_cols(&db, &rt, "t", &[("id", Type::ShortId), ("label", Type::Text)], &["id"]); + create_cols( + &db, + &rt, + "t", + &[("id", Type::ShortId), ("label", Type::Text)], + &["id"], + ); let result = run_sqlinsert(&db, &rt, "insert into t (label) values ('x') returning *") .expect("auto-fill INSERT … RETURNING * runs"); - assert_eq!(result.rows_affected, 1, "one row inserted (RETURNING-counted)"); + assert_eq!( + result.rows_affected, 1, + "one row inserted (RETURNING-counted)" + ); assert_eq!(result.data.rows.len(), 1, "RETURNING yielded the row"); // `id` is the auto-filled shortid column; it must be non-empty in // the returned row (proving the rewrite kept RETURNING). - let id_idx = result.data.columns.iter().position(|c| c == "id").expect("id column"); + let id_idx = result + .data + .columns + .iter() + .position(|c| c == "id") + .expect("id column"); let id_val = result.data.rows[0][id_idx].clone(); - assert!(id_val.is_some_and(|s| !s.is_empty()), "generated shortid surfaced via RETURNING"); + assert!( + id_val.is_some_and(|s| !s.is_empty()), + "generated shortid surfaced via RETURNING" + ); } #[test] @@ -790,11 +926,29 @@ fn insert_returning_recovers_bare_column_type() { // renders as the word, not 0/1). let (_project, db, _dir) = open_project_db(); let rt = rt(); - create_cols(&db, &rt, "t", &[("id", Type::Int), ("active", Type::Bool)], &["id"]); - let result = run_sqlinsert(&db, &rt, "insert into t (id, active) values (1, true) returning active") - .expect("INSERT … RETURNING active runs"); - assert_eq!(result.data.column_types, vec![Some(Type::Bool)], "bool type recovered"); - assert_eq!(result.data.rows[0][0], Some("true".to_string()), "rendered as the bool word"); + create_cols( + &db, + &rt, + "t", + &[("id", Type::Int), ("active", Type::Bool)], + &["id"], + ); + let result = run_sqlinsert( + &db, + &rt, + "insert into t (id, active) values (1, true) returning active", + ) + .expect("INSERT … RETURNING active runs"); + assert_eq!( + result.data.column_types, + vec![Some(Type::Bool)], + "bool type recovered" + ); + assert_eq!( + result.data.rows[0][0], + Some("true".to_string()), + "rendered as the bool word" + ); } #[test] @@ -803,11 +957,29 @@ fn insert_returning_computed_expression_is_typeless() { // so its recovered type is None (renders with neutral alignment). let (_project, db, _dir) = open_project_db(); let rt = rt(); - create_cols(&db, &rt, "t", &[("id", Type::Int), ("n", Type::Int)], &["id"]); - let result = run_sqlinsert(&db, &rt, "insert into t (id, n) values (1, 5) returning n + 1") - .expect("INSERT … RETURNING runs"); - assert_eq!(result.data.column_types, vec![None], "computed projection is typeless"); - assert_eq!(result.data.rows[0][0], Some("6".to_string()), "engine evaluated n + 1"); + create_cols( + &db, + &rt, + "t", + &[("id", Type::Int), ("n", Type::Int)], + &["id"], + ); + let result = run_sqlinsert( + &db, + &rt, + "insert into t (id, n) values (1, 5) returning n + 1", + ) + .expect("INSERT … RETURNING runs"); + assert_eq!( + result.data.column_types, + vec![None], + "computed projection is typeless" + ); + assert_eq!( + result.data.rows[0][0], + Some("6".to_string()), + "engine evaluated n + 1" + ); } #[test] @@ -858,7 +1030,13 @@ fn multirow_autofill_returning_yields_distinct_generated_ids() { // rows each carrying its own generated id. let (_project, db, _dir) = open_project_db(); let rt = rt(); - create_cols(&db, &rt, "t", &[("id", Type::ShortId), ("label", Type::Text)], &["id"]); + create_cols( + &db, + &rt, + "t", + &[("id", Type::ShortId), ("label", Type::Text)], + &["id"], + ); let result = run_sqlinsert( &db, &rt, @@ -867,11 +1045,25 @@ fn multirow_autofill_returning_yields_distinct_generated_ids() { .expect("multi-row auto-fill INSERT … RETURNING * runs"); assert_eq!(result.rows_affected, 3, "three rows inserted"); assert_eq!(result.data.rows.len(), 3, "three rows returned"); - let id_idx = result.data.columns.iter().position(|c| c == "id").expect("id column"); + let id_idx = result + .data + .columns + .iter() + .position(|c| c == "id") + .expect("id column"); let ids: std::collections::BTreeSet<_> = result.data.rows.iter().map(|r| r[id_idx].clone()).collect(); - assert_eq!(ids.len(), 3, "three DISTINCT generated ids via RETURNING: {:?}", result.data.rows); - assert!(ids.iter().all(|v| v.as_ref().is_some_and(|s| !s.is_empty())), "all ids non-empty"); + assert_eq!( + ids.len(), + 3, + "three DISTINCT generated ids via RETURNING: {:?}", + result.data.rows + ); + assert!( + ids.iter() + .all(|v| v.as_ref().is_some_and(|s| !s.is_empty())), + "all ids non-empty" + ); } #[test] @@ -881,15 +1073,39 @@ fn insert_select_returning_executes_and_returns_rows() { // source feeds the insert, and RETURNING yields the inserted rows). let (_project, db, _dir) = open_project_db(); let rt = rt(); - create_cols(&db, &rt, "src", &[("id", Type::Int), ("b", Type::Text)], &["id"]); - create_cols(&db, &rt, "dst", &[("id", Type::Int), ("b", Type::Text)], &["id"]); - run_sqlinsert(&db, &rt, "insert into src (id, b) values (1, 'x'), (2, 'y')").expect("seed src"); - let result = run_sqlinsert(&db, &rt, "insert into dst select * from src returning id, b") - .expect("INSERT … SELECT … RETURNING runs"); + create_cols( + &db, + &rt, + "src", + &[("id", Type::Int), ("b", Type::Text)], + &["id"], + ); + create_cols( + &db, + &rt, + "dst", + &[("id", Type::Int), ("b", Type::Text)], + &["id"], + ); + run_sqlinsert( + &db, + &rt, + "insert into src (id, b) values (1, 'x'), (2, 'y')", + ) + .expect("seed src"); + let result = run_sqlinsert( + &db, + &rt, + "insert into dst select * from src returning id, b", + ) + .expect("INSERT … SELECT … RETURNING runs"); assert_eq!(result.rows_affected, 2, "two rows copied"); - assert_eq!(result.data.rows.len(), 2, "RETURNING yielded both inserted rows"); - let bs: std::collections::BTreeSet<_> = - result.data.rows.iter().map(|r| r[1].clone()).collect(); + assert_eq!( + result.data.rows.len(), + 2, + "RETURNING yielded both inserted rows" + ); + let bs: std::collections::BTreeSet<_> = result.data.rows.iter().map(|r| r[1].clone()).collect(); assert!(bs.contains(&Some("x".to_string())) && bs.contains(&Some("y".to_string()))); } @@ -934,32 +1150,59 @@ fn autofill_upsert_real_conflict_preserves_clause_and_excluded() { "t".to_string(), vec![ ColumnSpec::new("id", Type::ShortId), - ColumnSpec { unique: true, ..ColumnSpec::new("code", Type::Text) }, + ColumnSpec { + unique: true, + ..ColumnSpec::new("code", Type::Text) + }, ColumnSpec::new("label", Type::Text), ], vec!["id".to_string()], None, )) .expect("create table with shortid pk + unique code"); - run_sqlinsert(&db, &rt, "insert into t (code, label) values ('A', 'first')").expect("seed"); + run_sqlinsert( + &db, + &rt, + "insert into t (code, label) values ('A', 'first')", + ) + .expect("seed"); let result = run_sqlinsert( &db, &rt, "insert into t (code, label) values ('A', 'second') on conflict (code) do update set label = excluded.label", ) .expect("auto-filled UPSERT with a real conflict (clause preserved)"); - assert_eq!(result.rows_affected, 1, "the conflicting row was updated, not inserted"); + assert_eq!( + result.rows_affected, 1, + "the conflicting row was updated, not inserted" + ); let rows = csv_rows(&project, "t"); - assert_eq!(rows.len(), 1, "still one row (DO UPDATE, not a second insert)"); - assert!(rows[0].iter().any(|c| c == "second"), "label updated via excluded: {rows:?}"); - assert!(!rows[0].iter().any(|c| c == "first"), "old label replaced: {rows:?}"); + assert_eq!( + rows.len(), + 1, + "still one row (DO UPDATE, not a second insert)" + ); + assert!( + rows[0].iter().any(|c| c == "second"), + "label updated via excluded: {rows:?}" + ); + assert!( + !rows[0].iter().any(|c| c == "first"), + "old label replaced: {rows:?}" + ); } #[test] fn on_conflict_do_nothing_keeps_existing_row() { let (project, db, _dir) = open_project_db(); let rt = rt(); - create_cols(&db, &rt, "t", &[("id", Type::Int), ("name", Type::Text)], &["id"]); + create_cols( + &db, + &rt, + "t", + &[("id", Type::Int), ("name", Type::Text)], + &["id"], + ); run_sqlinsert(&db, &rt, "insert into t (id, name) values (1, 'orig')").expect("seed"); let result = run_sqlinsert( &db, @@ -970,14 +1213,23 @@ fn on_conflict_do_nothing_keeps_existing_row() { assert_eq!(result.rows_affected, 0, "conflicting row left untouched"); let rows = csv_rows(&project, "t"); assert_eq!(rows.len(), 1, "still one row"); - assert!(rows[0].iter().any(|c| c == "orig"), "original value kept: {rows:?}"); + assert!( + rows[0].iter().any(|c| c == "orig"), + "original value kept: {rows:?}" + ); } #[test] fn on_conflict_do_update_applies_excluded() { let (project, db, _dir) = open_project_db(); let rt = rt(); - create_cols(&db, &rt, "t", &[("id", Type::Int), ("name", Type::Text)], &["id"]); + create_cols( + &db, + &rt, + "t", + &[("id", Type::Int), ("name", Type::Text)], + &["id"], + ); run_sqlinsert(&db, &rt, "insert into t (id, name) values (1, 'orig')").expect("seed"); let result = run_sqlinsert( &db, @@ -988,14 +1240,23 @@ fn on_conflict_do_update_applies_excluded() { assert_eq!(result.rows_affected, 1, "the conflicting row was updated"); let rows = csv_rows(&project, "t"); assert_eq!(rows.len(), 1, "still one row (updated, not inserted)"); - assert!(rows[0].iter().any(|c| c == "new"), "row updated to excluded.name: {rows:?}"); + assert!( + rows[0].iter().any(|c| c == "new"), + "row updated to excluded.name: {rows:?}" + ); } #[test] fn on_conflict_do_nothing_without_target() { let (_project, db, _dir) = open_project_db(); let rt = rt(); - create_cols(&db, &rt, "t", &[("id", Type::Int), ("name", Type::Text)], &["id"]); + create_cols( + &db, + &rt, + "t", + &[("id", Type::Int), ("name", Type::Text)], + &["id"], + ); run_sqlinsert(&db, &rt, "insert into t (id, name) values (1, 'orig')").expect("seed"); let result = run_sqlinsert( &db, @@ -1003,7 +1264,10 @@ fn on_conflict_do_nothing_without_target() { "insert into t (id, name) values (1, 'x') on conflict do nothing", ) .expect("ON CONFLICT (no target) DO NOTHING runs"); - assert_eq!(result.rows_affected, 0, "any-conflict do-nothing absorbed the duplicate"); + assert_eq!( + result.rows_affected, 0, + "any-conflict do-nothing absorbed the duplicate" + ); } #[test] @@ -1017,7 +1281,13 @@ fn autofill_preserves_on_conflict_clause() { // the rewrite doesn't prepare-fail and the clause survives. let (project, db, _dir) = open_project_db(); let rt = rt(); - create_cols(&db, &rt, "t", &[("id", Type::ShortId), ("label", Type::Text)], &["id"]); + create_cols( + &db, + &rt, + "t", + &[("id", Type::ShortId), ("label", Type::Text)], + &["id"], + ); let result = run_sqlinsert( &db, &rt, @@ -1054,7 +1324,10 @@ fn sql_dml_validates_literal_values_like_the_dsl() { let dsl = r.block_on(db.insert( "T".to_string(), Some(vec!["id".to_string(), "d".to_string()]), - vec![Value::Number("1".to_string()), Value::Text("2025/01/15".to_string())], + vec![ + Value::Number("1".to_string()), + Value::Text("2025/01/15".to_string()), + ], Some("insert".to_string()), )); assert!( @@ -1187,20 +1460,34 @@ fn advanced_upsert_do_update_set_offers_typed_slot_hint() { // column-typed slot hint (boundary-aware lookahead → typed slot). let mut cache = SchemaCache::default(); let cols = vec![ - TableColumn { name: "id".to_string(), user_type: Type::Int, not_null: false, has_default: false }, - TableColumn { name: "Name".to_string(), user_type: Type::Text, not_null: false, has_default: false }, + TableColumn { + name: "id".to_string(), + user_type: Type::Int, + not_null: false, + has_default: false, + }, + TableColumn { + name: "Name".to_string(), + user_type: Type::Text, + not_null: false, + has_default: false, + }, ]; cache.tables.push("Customers".to_string()); cache.columns.push("id".to_string()); cache.columns.push("Name".to_string()); cache.table_columns.insert("Customers".to_string(), cols); - let input = "insert into Customers (id, Name) values (1, 'x') on conflict (id) do update set Name="; + let input = + "insert into Customers (id, Name) values (1, 'x') on conflict (id) do update set Name="; let hint = ambient_hint_in_mode(input, input.len(), None, &cache, Mode::Advanced); let Some(AmbientHint::Prose(prose)) = hint else { panic!("expected a Prose hint at the UPSERT SET value slot, got {hint:?}"); }; - assert!(prose.contains("Name"), "hint names the column `Name`: {prose:?}"); + assert!( + prose.contains("Name"), + "hint names the column `Name`: {prose:?}" + ); assert!( prose.contains("quoted string"), "text-column hint says `quoted string`: {prose:?}" @@ -1251,8 +1538,14 @@ fn advanced_insert_form_a_value_offers_typed_slot_hint() { // user-listed column, so the hint is that column's typed prose. let schema = vschema(&[("Things", &[("k", Type::Int), ("note", Type::Text)])]); let prose = prose_at("insert into Things (note) values (", &schema); - assert!(prose.contains("note"), "names listed column `note`: {prose:?}"); - assert!(prose.contains("quoted string"), "text-column prose: {prose:?}"); + assert!( + prose.contains("note"), + "names listed column `note`: {prose:?}" + ); + assert!( + prose.contains("quoted string"), + "text-column prose: {prose:?}" + ); } #[test] @@ -1271,8 +1564,14 @@ fn advanced_insert_second_position_hints_second_column() { // hint is the SECOND column's typed prose. let schema = vschema(&[("Things", &[("k", Type::Int), ("note", Type::Text)])]); let prose = prose_at("insert into Things (k, note) values (5, ", &schema); - assert!(prose.contains("note"), "second position names `note`: {prose:?}"); - assert!(prose.contains("quoted string"), "text-column prose: {prose:?}"); + assert!( + prose.contains("note"), + "second position names `note`: {prose:?}" + ); + assert!( + prose.contains("quoted string"), + "text-column prose: {prose:?}" + ); } #[test] @@ -1283,13 +1582,19 @@ fn advanced_insert_value_int_mismatch_is_caught_live() { &schema, Mode::Advanced, ); - assert!(!matches!(bad, InputState::Valid), "decimal into int rejected live: {bad:?}"); + assert!( + !matches!(bad, InputState::Valid), + "decimal into int rejected live: {bad:?}" + ); let ok = classify_input_with_schema_in_mode( "insert into Things (k) values (5)", &schema, Mode::Advanced, ); - assert!(matches!(ok, InputState::Valid), "valid int literal parses: {ok:?}"); + assert!( + matches!(ok, InputState::Valid), + "valid int literal parses: {ok:?}" + ); } #[test] @@ -1303,7 +1608,10 @@ fn advanced_insert_string_into_int_is_caught_live() { &schema, Mode::Advanced, ); - assert!(!matches!(bad, InputState::Valid), "string into int rejected live: {bad:?}"); + assert!( + !matches!(bad, InputState::Valid), + "string into int rejected live: {bad:?}" + ); } #[test] @@ -1314,7 +1622,10 @@ fn advanced_insert_multi_row_typed_and_mismatch_caught() { &schema, Mode::Advanced, ); - assert!(matches!(ok, InputState::Valid), "well-formed multi-row parses: {ok:?}"); + assert!( + matches!(ok, InputState::Valid), + "well-formed multi-row parses: {ok:?}" + ); let bad = classify_input_with_schema_in_mode( "insert into Things (k, note) values (1, 'a'), (3.14, 'b')", &schema, @@ -1333,14 +1644,21 @@ fn advanced_insert_form_b_maps_all_columns_including_serial() { // takes an int literal (unlike the DSL, which omits auto-gen cols). let schema = vschema(&[( "Customers", - &[("id", Type::Serial), ("Name", Type::Text), ("Email", Type::Text)], + &[ + ("id", Type::Serial), + ("Name", Type::Text), + ("Email", Type::Text), + ], )]); let state = classify_input_with_schema_in_mode( "insert into Customers values (1, 'Bob', 'b@c')", &schema, Mode::Advanced, ); - assert!(matches!(state, InputState::Valid), "Form B maps all 3 columns: {state:?}"); + assert!( + matches!(state, InputState::Valid), + "Form B maps all 3 columns: {state:?}" + ); } #[test] @@ -1356,6 +1674,9 @@ fn advanced_insert_value_expressions_still_parse_via_sql_expr() { "insert into Things (k) values ((select 1))", ] { let state = classify_input_with_schema_in_mode(input, &schema, Mode::Advanced); - assert!(matches!(state, InputState::Valid), "{input:?} must parse: {state:?}"); + assert!( + matches!(state, InputState::Valid), + "{input:?} must parse: {state:?}" + ); } } diff --git a/tests/it/sql_select.rs b/tests/it/sql_select.rs index c3c31d4..6d6aa35 100644 --- a/tests/it/sql_select.rs +++ b/tests/it/sql_select.rs @@ -57,11 +57,13 @@ fn advanced_mode_select_dispatches_as_command_select() { type_str(&mut app, "select 1"); let actions = submit(&mut app); match actions.as_slice() { - [Action::ExecuteDsl { - command: Command::Select { sql }, - source, - .. - }] => { + [ + Action::ExecuteDsl { + command: Command::Select { sql }, + source, + .. + }, + ] => { assert!( sql.contains("select 1"), "Command::Select carries the validated SQL text: {sql:?}", @@ -118,10 +120,12 @@ fn colon_one_shot_from_simple_mode_dispatches_select() { // Persistent mode is unchanged. assert_eq!(app.mode, Mode::Simple); match actions.as_slice() { - [Action::ExecuteDsl { - command: Command::Select { sql }, - .. - }] => { + [ + Action::ExecuteDsl { + command: Command::Select { sql }, + .. + }, + ] => { assert!( sql.contains("select 1") && !sql.starts_with(':'), "the `:` is stripped before the SQL is queued: {sql:?}", @@ -167,8 +171,7 @@ fn rt() -> tokio::runtime::Runtime { fn open_project_db() -> (project::Project, Database, tempfile::TempDir) { let dir = tempfile::tempdir().expect("create tempdir"); - let project = - project::open_or_create(None, Some(dir.path())).expect("open or create project"); + let project = project::open_or_create(None, Some(dir.path())).expect("open or create project"); let persistence = Persistence::new(project.path().to_path_buf()); let db = Database::open_with_persistence(project.db_path(), persistence) .expect("open db with persistence"); @@ -315,22 +318,12 @@ fn database_run_select_recovers_bool_column_type() { ) .await .expect("create table"); - db.insert( - "Products".to_string(), - None, - vec![Value::Bool(true)], - None, - ) - .await - .expect("insert row"); - db.insert( - "Products".to_string(), - None, - vec![Value::Bool(false)], - None, - ) - .await - .expect("insert row"); + db.insert("Products".to_string(), None, vec![Value::Bool(true)], None) + .await + .expect("insert row"); + db.insert("Products".to_string(), None, vec![Value::Bool(false)], None) + .await + .expect("insert row"); }); let data = rt .block_on(db.run_select("select Active from Products".to_string())) @@ -370,9 +363,7 @@ fn database_run_select_recovers_text_type_through_alias() { // origin metadata still points at `Users.Name`, so the // playground type is recovered. let data = rt - .block_on( - db.run_select("select Name as n from Users".to_string()), - ) + .block_on(db.run_select("select Name as n from Users".to_string())) .expect("SELECT runs"); assert_eq!(data.columns, vec!["n".to_string()]); assert_eq!(data.column_types, vec![Some(Type::Text)]); @@ -394,9 +385,14 @@ fn database_run_select_computed_expression_stays_typeless() { ) .await .expect("create table"); - db.insert("T".to_string(), None, vec![Value::Number("5".to_string())], None) - .await - .expect("insert"); + db.insert( + "T".to_string(), + None, + vec![Value::Number("5".to_string())], + None, + ) + .await + .expect("insert"); }); let data = rt .block_on(db.run_select("select Score + 1 from T".to_string())) @@ -434,17 +430,12 @@ fn engine_aggregate_in_where_routes_through_catalog() { // ADR-0032 §11.4. Run the bad query and confirm the // friendly layer routes the message through engine.aggregate_misuse. let err = rt - .block_on(db.run_select( - "select id from T where count(score) > 0".to_string(), - )) + .block_on(db.run_select("select id from T where count(score) > 0".to_string())) .expect_err("engine should reject aggregate in WHERE"); let DbError::Sqlite { .. } = &err else { panic!("expected Sqlite engine error; got {err:?}"); }; - let friendly = friendly::translate_error( - &err, - &friendly::TranslateContext::default(), - ); + let friendly = friendly::translate_error(&err, &friendly::TranslateContext::default()); let rendered = friendly.render(); assert!( rendered.contains("aggregate"), @@ -506,23 +497,17 @@ fn engine_group_by_missing_routes_through_catalog() { // false-positive on phrasings that happen to contain // "group by" elsewhere. Any successful query is fine. let _ = rt - .block_on(db.run_select( - "select category, count(*) from T group by category".to_string(), - )) + .block_on(db.run_select("select category, count(*) from T group by category".to_string())) .expect("benign GROUP BY query runs"); // Direct unit test on the matcher: ensure a message that // mentions GROUP BY routes through the catalog. let synthetic = DbError::Sqlite { - message: - "column must appear in the GROUP BY clause or be used in an aggregate function" - .to_string(), + message: "column must appear in the GROUP BY clause or be used in an aggregate function" + .to_string(), kind: rdbms_playground::db::SqliteErrorKind::Other, }; - let rendered = friendly::translate_error( - &synthetic, - &friendly::TranslateContext::default(), - ) - .render(); + let rendered = + friendly::translate_error(&synthetic, &friendly::TranslateContext::default()).render(); assert!( rendered.contains("GROUP BY"), "engine.group_by_required wording missing; got {rendered:?}", @@ -567,19 +552,14 @@ fn engine_scalar_subquery_too_many_rows_routes_through_catalog() { // catalog (the matcher would fire if SQLite ever // surfaced this verbatim). let _ = rt - .block_on(db.run_select( - "select (select v from T) from T".to_string(), - )) + .block_on(db.run_select("select (select v from T) from T".to_string())) .expect("benign scalar subquery query runs"); let synthetic = DbError::Sqlite { message: "scalar subquery returned more than one row".to_string(), kind: rdbms_playground::db::SqliteErrorKind::Other, }; - let rendered = friendly::translate_error( - &synthetic, - &friendly::TranslateContext::default(), - ) - .render(); + let rendered = + friendly::translate_error(&synthetic, &friendly::TranslateContext::default()).render(); assert!( rendered.contains("more than one row"), "engine.scalar_subquery_too_many_rows wording missing; got {rendered:?}", diff --git a/tests/it/sql_update.rs b/tests/it/sql_update.rs index 88ef502..5c3d204 100644 --- a/tests/it/sql_update.rs +++ b/tests/it/sql_update.rs @@ -28,8 +28,7 @@ fn rt() -> tokio::runtime::Runtime { fn open_project_db() -> (project::Project, Database, tempfile::TempDir) { let dir = tempfile::tempdir().expect("create tempdir"); - let project = - project::open_or_create(None, Some(dir.path())).expect("open or create project"); + let project = project::open_or_create(None, Some(dir.path())).expect("open or create project"); let persistence = Persistence::new(project.path().to_path_buf()); let db = Database::open_with_persistence(project.db_path(), persistence) .expect("open db with persistence"); @@ -77,15 +76,18 @@ fn run_update( input: &str, ) -> Result { match parse_command(input).expect("parse update") { - Command::SqlUpdate { sql, target_table, returning, set_literals } => rt.block_on( - db.run_sql_update_with_literals( - sql, - Some(input.to_string()), - target_table, - returning, - set_literals, - ), - ), + Command::SqlUpdate { + sql, + target_table, + returning, + set_literals, + } => rt.block_on(db.run_sql_update_with_literals( + sql, + Some(input.to_string()), + target_table, + returning, + set_literals, + )), other => panic!("expected Command::SqlUpdate, got {other:?}"), } } @@ -95,7 +97,9 @@ fn parse_path_lowers_sql_update_to_command() { let command = parse_command("update Orders set total = 0 where id = 1") .expect("update parses in advanced mode"); match command { - Command::SqlUpdate { sql, target_table, .. } => { + Command::SqlUpdate { + sql, target_table, .. + } => { assert_eq!(sql, "update Orders set total = 0 where id = 1"); assert_eq!(target_table, "Orders"); } @@ -107,10 +111,20 @@ fn parse_path_lowers_sql_update_to_command() { fn single_column_update_with_where_persists() { 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'), (2, 'keep')", "t"); - let result = run_update(&db, &rt, "update t set v = 'new' where id = 1") - .expect("update runs"); + create_cols( + &db, + &rt, + "t", + &[("id", Type::Int), ("v", Type::Text)], + &["id"], + ); + seed( + &db, + &rt, + "insert into t (id, v) values (1, 'old'), (2, 'keep')", + "t", + ); + let result = run_update(&db, &rt, "update t set v = 'new' where id = 1").expect("update runs"); assert_eq!(result.rows_affected, 1, "one row updated"); let csv = read_csv(&project, "t").expect("t.csv"); assert!(csv.contains("new"), "updated value present: {csv:?}"); @@ -134,7 +148,10 @@ fn multi_column_update_persists() { .expect("multi-col update runs"); assert_eq!(result.rows_affected, 1); let csv = read_csv(&project, "t").expect("t.csv"); - assert!(csv.contains('9') && csv.contains('y'), "both columns updated: {csv:?}"); + assert!( + csv.contains('9') && csv.contains('y'), + "both columns updated: {csv:?}" + ); } #[test] @@ -142,10 +159,21 @@ fn update_without_where_runs_across_all_rows() { // ADR-0030 §12: no `--all-rows` rail. let (project, db, _dir) = open_project_db(); let rt = rt(); - create_cols(&db, &rt, "t", &[("id", Type::Int), ("active", Type::Bool)], &["id"]); - seed(&db, &rt, "insert into t (id, active) values (1, true), (2, true)", "t"); - let result = run_update(&db, &rt, "update t set active = false") - .expect("unfiltered update runs"); + create_cols( + &db, + &rt, + "t", + &[("id", Type::Int), ("active", Type::Bool)], + &["id"], + ); + seed( + &db, + &rt, + "insert into t (id, active) values (1, true), (2, true)", + "t", + ); + let result = + run_update(&db, &rt, "update t set active = false").expect("unfiltered update runs"); assert_eq!(result.rows_affected, 2, "all rows updated"); let csv = read_csv(&project, "t").expect("t.csv"); assert!(!csv.contains("true"), "no row left active: {csv:?}"); @@ -159,10 +187,20 @@ fn update_with_sql_expr_in_set() { &db, &rt, "t", - &[("id", Type::Int), ("price", Type::Int), ("qty", Type::Int), ("total", Type::Int)], + &[ + ("id", Type::Int), + ("price", Type::Int), + ("qty", Type::Int), + ("total", Type::Int), + ], &["id"], ); - seed(&db, &rt, "insert into t (id, price, qty, total) values (1, 6, 7, 0)", "t"); + seed( + &db, + &rt, + "insert into t (id, price, qty, total) values (1, 6, 7, 0)", + "t", + ); let result = run_update(&db, &rt, "update t set total = price * qty where id = 1") .expect("expression update runs"); assert_eq!(result.rows_affected, 1); @@ -176,8 +214,19 @@ fn update_with_subquery_in_set() { let (project, db, _dir) = open_project_db(); let rt = rt(); create_cols(&db, &rt, "other", &[("n", Type::Int)], &["n"]); - create_cols(&db, &rt, "t", &[("id", Type::Int), ("v", Type::Int)], &["id"]); - seed(&db, &rt, "insert into other (n) values (3), (8), (5)", "other"); + create_cols( + &db, + &rt, + "t", + &[("id", Type::Int), ("v", Type::Int)], + &["id"], + ); + seed( + &db, + &rt, + "insert into other (n) values (3), (8), (5)", + "other", + ); seed(&db, &rt, "insert into t (id, v) values (1, 0)", "t"); let result = run_update( &db, @@ -196,13 +245,22 @@ fn update_matching_no_rows_is_ok() { // the path doesn't crash, and the CSV is unchanged. let (project, db, _dir) = open_project_db(); let rt = rt(); - create_cols(&db, &rt, "t", &[("id", Type::Int), ("v", Type::Text)], &["id"]); + create_cols( + &db, + &rt, + "t", + &[("id", Type::Int), ("v", Type::Text)], + &["id"], + ); seed(&db, &rt, "insert into t (id, v) values (1, 'keep')", "t"); let result = run_update(&db, &rt, "update t set v = 'x' where id = 999") .expect("no-match update is a success"); assert_eq!(result.rows_affected, 0, "no rows matched"); let csv = read_csv(&project, "t").expect("t.csv"); - assert!(csv.contains("keep") && !csv.contains('x'), "unchanged: {csv:?}"); + assert!( + csv.contains("keep") && !csv.contains('x'), + "unchanged: {csv:?}" + ); } // ================================================================= @@ -220,8 +278,19 @@ fn sql_update_validates_set_literals_like_the_dsl() { // STRICT TEXT column accept anything). let (project, db, _dir) = open_project_db(); let rt = rt(); - create_cols(&db, &rt, "t", &[("id", Type::Int), ("d", Type::Date)], &["id"]); - seed(&db, &rt, "insert into t (id, d) values (1, '2025-01-15')", "t"); + create_cols( + &db, + &rt, + "t", + &[("id", Type::Int), ("d", Type::Date)], + &["id"], + ); + seed( + &db, + &rt, + "insert into t (id, d) values (1, '2025-01-15')", + "t", + ); // SQL path (advanced mode, full replay pipeline) — REJECTS the bad date. std::fs::write( @@ -300,7 +369,12 @@ fn sql_update_validates_every_assignment_not_just_the_first() { &[("id", Type::Int), ("v", Type::Text), ("d", Type::Date)], &["id"], ); - seed(&db, &rt, "insert into t (id, v, d) values (1, 'a', '2025-01-01')", "t"); + seed( + &db, + &rt, + "insert into t (id, v, d) values (1, 'a', '2025-01-01')", + "t", + ); std::fs::write( project.path().join("multi.commands"), "update t set v = 'ok', d = '2025/01/15' where id = 1\n", @@ -321,26 +395,64 @@ fn sql_update_validates_every_assignment_not_just_the_first() { fn update_returning_yields_modified_columns() { 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'), (2, 'keep')", "t"); - let result = run_update(&db, &rt, "update t set v = 'new' where id = 1 returning id, v") - .expect("UPDATE … RETURNING runs"); + create_cols( + &db, + &rt, + "t", + &[("id", Type::Int), ("v", Type::Text)], + &["id"], + ); + seed( + &db, + &rt, + "insert into t (id, v) values (1, 'old'), (2, 'keep')", + "t", + ); + let result = run_update( + &db, + &rt, + "update t set v = 'new' where id = 1 returning id, v", + ) + .expect("UPDATE … RETURNING runs"); assert_eq!(result.rows_affected, 1, "one row updated"); assert_eq!(result.data.columns, vec!["id".to_string(), "v".to_string()]); assert_eq!(result.data.rows.len(), 1); // RETURNING reflects the POST-update value. - assert_eq!(result.data.rows[0][1], Some("new".to_string()), "modified value returned"); + assert_eq!( + result.data.rows[0][1], + Some("new".to_string()), + "modified value returned" + ); } #[test] fn update_returning_recovers_bare_column_type() { let (_project, db, _dir) = open_project_db(); let rt = rt(); - create_cols(&db, &rt, "t", &[("id", Type::Int), ("active", Type::Bool)], &["id"]); - seed(&db, &rt, "insert into t (id, active) values (1, false)", "t"); - let result = run_update(&db, &rt, "update t set active = true where id = 1 returning active") - .expect("UPDATE … RETURNING active runs"); - assert_eq!(result.data.column_types, vec![Some(Type::Bool)], "bool type recovered"); + create_cols( + &db, + &rt, + "t", + &[("id", Type::Int), ("active", Type::Bool)], + &["id"], + ); + seed( + &db, + &rt, + "insert into t (id, active) values (1, false)", + "t", + ); + let result = run_update( + &db, + &rt, + "update t set active = true where id = 1 returning active", + ) + .expect("UPDATE … RETURNING active runs"); + assert_eq!( + result.data.column_types, + vec![Some(Type::Bool)], + "bool type recovered" + ); assert_eq!(result.data.rows[0][0], Some("true".to_string())); } @@ -352,13 +464,27 @@ fn update_returning_matching_no_rows_is_ok_and_empty() { // phantom row. let (_project, db, _dir) = open_project_db(); let rt = rt(); - create_cols(&db, &rt, "t", &[("id", Type::Int), ("v", Type::Text)], &["id"]); + create_cols( + &db, + &rt, + "t", + &[("id", Type::Int), ("v", Type::Text)], + &["id"], + ); seed(&db, &rt, "insert into t (id, v) values (1, 'keep')", "t"); - let result = run_update(&db, &rt, "update t set v = 'x' where id = 999 returning id, v") - .expect("no-match UPDATE … RETURNING is a success"); + let result = run_update( + &db, + &rt, + "update t set v = 'x' where id = 999 returning id, v", + ) + .expect("no-match UPDATE … RETURNING is a success"); assert_eq!(result.rows_affected, 0, "no rows matched"); assert!(result.data.rows.is_empty(), "no rows returned"); - assert_eq!(result.data.columns, vec!["id".to_string(), "v".to_string()], "columns still present"); + assert_eq!( + result.data.columns, + vec!["id".to_string(), "v".to_string()], + "columns still present" + ); } // ================================================================= @@ -399,14 +525,21 @@ fn advanced_update_set_value_offers_typed_slot_hint_for_column() { // instead of the type-blind sql_expr surface. let schema = schema_cache(&[( "Customers", - &[("id", Type::Serial), ("Name", Type::Text), ("Email", Type::Text)], + &[ + ("id", Type::Serial), + ("Name", Type::Text), + ("Email", Type::Text), + ], )]); let input = "update Customers set Email="; let hint = ambient_hint_in_mode(input, input.len(), None, &schema, Mode::Advanced); let Some(AmbientHint::Prose(prose)) = hint else { panic!("expected a Prose hint at the typed value slot, got {hint:?}"); }; - assert!(prose.contains("Email"), "hint names the column `Email`: {prose:?}"); + assert!( + prose.contains("Email"), + "hint names the column `Email`: {prose:?}" + ); assert!( prose.contains("quoted string"), "text-column hint says `quoted string`: {prose:?}" @@ -449,7 +582,10 @@ fn advanced_update_set_int_value_type_mismatch_is_caught_live() { &schema, Mode::Advanced, ); - assert!(matches!(ok, InputState::Valid), "a valid int literal parses: {ok:?}"); + assert!( + matches!(ok, InputState::Valid), + "a valid int literal parses: {ok:?}" + ); } #[test] @@ -463,10 +599,10 @@ fn advanced_update_set_expression_still_parses_via_sql_expr() { ("other", &[("n", Type::Int)]), ]); for input in [ - "update Things set k = 3 + 2 where k = 0", // literal-prefixed expression + "update Things set k = 3 + 2 where k = 0", // literal-prefixed expression "update Things set k = (select max(n) from other) where k = 0", // scalar subquery "update Things set note = upper(note) where k = 0", // function call - "update Things set k = -5 where k = 0", // signed number → sql_expr + "update Things set k = -5 where k = 0", // signed number → sql_expr ] { let state = classify_input_with_schema_in_mode(input, &schema, Mode::Advanced); assert!( @@ -488,7 +624,13 @@ fn update_all_rows_flag_in_advanced_updates_every_row() { // parse-level dispatch (covered in tests/sql_dml_e2e.rs). let (project, db, _dir) = open_project_db(); let rt = rt(); - create_cols(&db, &rt, "t", &[("id", Type::Int), ("v", Type::Int)], &["id"]); + create_cols( + &db, + &rt, + "t", + &[("id", Type::Int), ("v", Type::Int)], + &["id"], + ); seed(&db, &rt, "insert into t (id, v) values (1, 1), (2, 2)", "t"); std::fs::write( project.path().join("allrows.commands"), @@ -497,7 +639,10 @@ fn update_all_rows_flag_in_advanced_updates_every_row() { .expect("write script"); let events = rt.block_on(run_replay(&db, project.path(), "allrows.commands")); assert!( - matches!(events.last(), Some(AppEvent::ReplayCompleted { count: 1, .. })), + matches!( + events.last(), + Some(AppEvent::ReplayCompleted { count: 1, .. }) + ), "the --all-rows update replays through the DSL fall-back; events: {events:?}" ); let rows = rt diff --git a/tests/it/undo_snapshots.rs b/tests/it/undo_snapshots.rs index c0ce5f2..17f4189 100644 --- a/tests/it/undo_snapshots.rs +++ b/tests/it/undo_snapshots.rs @@ -179,7 +179,10 @@ fn undo_disabled_takes_no_snapshots() { .unwrap(); // Nothing to undo, and no snapshot machinery on disk. - assert!(db.undo().await.unwrap().is_none(), "undo is a no-op when disabled"); + assert!( + db.undo().await.unwrap().is_none(), + "undo is a no-op when disabled" + ); assert!(db.peek_undo().await.unwrap().is_none()); }); @@ -360,7 +363,10 @@ fn undo_restores_db_and_csv_consistently() { db.insert( "T".to_string(), Some(vec!["id".to_string(), "name".to_string()]), - vec![Value::Number("1".to_string()), Value::Text("Alice".to_string())], + vec![ + Value::Number("1".to_string()), + Value::Text("Alice".to_string()), + ], Some("insert Alice".to_string()), ) .await @@ -444,8 +450,8 @@ fn undo_ring_persists_across_reopen() { // held by `project`). The persisted ring must survive. drop(db); let persistence = Persistence::new(path); - let db2 = Database::open_with_persistence_and_undo(&db_path, persistence, true) - .expect("reopen db"); + let db2 = + Database::open_with_persistence_and_undo(&db_path, persistence, true).expect("reopen db"); rt().block_on(async { let peek = db2 diff --git a/tests/it/walking_skeleton.rs b/tests/it/walking_skeleton.rs index 40cea3c..c2dec15 100644 --- a/tests/it/walking_skeleton.rs +++ b/tests/it/walking_skeleton.rs @@ -49,7 +49,11 @@ fn submit(app: &mut App) -> Vec { /// and don't care about the verbatim user input. #[track_caller] fn assert_one_execute_dsl(actions: &[Action], expected: &Command) { - assert_eq!(actions.len(), 1, "expected exactly one action; got {actions:?}"); + assert_eq!( + actions.len(), + 1, + "expected exactly one action; got {actions:?}" + ); match &actions[0] { Action::ExecuteDsl { command, .. } => assert_eq!(command, expected), other => panic!("expected ExecuteDsl, got {other:?}"), @@ -234,9 +238,18 @@ fn status_bar_is_keystroke_only_and_state_aware() { // Default (empty input): nav / complete / history / run keystrokes. let default_view = rendered_text(&mut app, &theme, 80, 24); - assert!(default_view.contains("Ctrl-O sidebar"), "strip lists sidebar:\n{default_view}"); - 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("Ctrl-O sidebar"), + "strip lists sidebar:\n{default_view}" + ); + 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}", @@ -245,8 +258,14 @@ fn status_bar_is_keystroke_only_and_state_aware() { // Editing (input has text): the #29 readline edit keys appear. type_str(&mut app, "create"); let editing = rendered_text(&mut app, &theme, 80, 24); - assert!(editing.contains("Esc clear"), "editing strip lists clear:\n{editing}"); - assert!(editing.contains("Ctrl-W del word"), "editing strip lists del word:\n{editing}"); + assert!( + editing.contains("Esc clear"), + "editing strip lists clear:\n{editing}" + ); + assert!( + editing.contains("Ctrl-W del word"), + "editing strip lists del word:\n{editing}" + ); } // --------------------------------------------------------------- @@ -326,7 +345,9 @@ fn create_table_flow_updates_tables_list_and_structure_view() { // `id` row shows both the name and its `serial` type // separated by box-drawing characters. assert!( - rendered.lines().any(|l| l.contains("id") && l.contains("serial")), + rendered + .lines() + .any(|l| l.contains("id") && l.contains("serial")), "output should show the id/serial column row:\n{rendered}" ); } @@ -336,10 +357,7 @@ fn add_column_flow_updates_structure_view() { let mut app = App::new(); // Simulate the prior create_table state. app.tables = vec!["Customers".to_string()]; - app.current_table = Some(fake_table( - "Customers", - &[("id", Type::Serial, true)], - )); + app.current_table = Some(fake_table("Customers", &[("id", Type::Serial, true)])); type_str(&mut app, "add column to table Customers: Name (text)"); let actions = submit(&mut app); @@ -376,7 +394,9 @@ fn add_column_flow_updates_structure_view() { assert_eq!(app.current_table, Some(updated)); let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24); assert!( - rendered.lines().any(|l| l.contains("Name") && l.contains("text")), + rendered + .lines() + .any(|l| l.contains("Name") && l.contains("text")), "expected the Name/text column row:\n{rendered}", ); } @@ -496,10 +516,7 @@ fn add_relationship_flow_shows_parent_side_with_inbound_section() { assert!(rendered.contains("on delete cascade"), "{rendered}"); // The [ok] subject lists the endpoints. Long lines wrap in // the panel, so we check the first half of the phrase only. - assert!( - rendered.contains("from Customers.Id"), - "{rendered}" - ); + assert!(rendered.contains("from Customers.Id"), "{rendered}"); } #[test] @@ -551,11 +568,23 @@ fn add_column_confirmation_omits_relationship_prose() { let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24); // The structure box still renders (table name + the column box from // the returned description). - assert!(rendered.contains("Customers"), "structure header:\n{rendered}"); - assert!(rendered.contains("Constraints"), "structure box:\n{rendered}"); + assert!( + rendered.contains("Customers"), + "structure header:\n{rendered}" + ); + assert!( + rendered.contains("Constraints"), + "structure box:\n{rendered}" + ); // The relationship block is gone — neither prose heading nor line. - assert!(!rendered.contains("Referenced by:"), "no prose heading:\n{rendered}"); - assert!(!rendered.contains("References:"), "no prose heading:\n{rendered}"); + assert!( + !rendered.contains("Referenced by:"), + "no prose heading:\n{rendered}" + ); + assert!( + !rendered.contains("References:"), + "no prose heading:\n{rendered}" + ); assert!( !rendered.contains("Orders.CustId → Id"), "no prose line:\n{rendered}", @@ -682,8 +711,14 @@ fn validity_indicator_renders_err_and_wrn_labels() { let mut app = App::new(); let clean = rendered_text(&mut app, &Theme::dark(), 80, 24); - assert!(!clean.contains("[ERR]"), "clean input shows no label:\n{clean}"); - assert!(!clean.contains("[WRN]"), "clean input shows no label:\n{clean}"); + assert!( + !clean.contains("[ERR]"), + "clean input shows no label:\n{clean}" + ); + assert!( + !clean.contains("[WRN]"), + "clean input shows no label:\n{clean}" + ); app.input_indicator = Some(Severity::Error); let err = rendered_text(&mut app, &Theme::dark(), 80, 24); diff --git a/tests/typing_surface/add_relationship.rs b/tests/typing_surface/add_relationship.rs index 88ed820..512d84e 100644 --- a/tests/typing_surface/add_relationship.rs +++ b/tests/typing_surface/add_relationship.rs @@ -28,10 +28,7 @@ fn one_n_relationship_keyword_sequence_is_incomplete() { #[test] fn after_from_offers_table_names() { let schema = schema_multi_table(); - let a = assess_at_end( - "add 1:n relationship from ", - &schema, - ); + let a = assess_at_end("add 1:n relationship from ", &schema); assert!(matches!(a.state, InputState::IncompleteAtEof)); assert_candidate_present(&a, &["Customers", "Orders"]); crate::snap!("after_from", a); @@ -41,10 +38,7 @@ fn after_from_offers_table_names() { fn after_parent_table_dot_narrows_to_parent_columns() { // §2.2 follow-up — `from Customers.` narrows to Customers. let schema = schema_multi_table(); - let a = assess_at_end( - "add 1:n relationship from Customers.", - &schema, - ); + let a = assess_at_end("add 1:n relationship from Customers.", &schema); assert!(matches!(a.state, InputState::IncompleteAtEof)); assert_candidate_present(&a, &["id", "Name"]); assert_no_candidate_named(&a, &["OrderId", "CustId", "Total"]); @@ -54,10 +48,7 @@ fn after_parent_table_dot_narrows_to_parent_columns() { #[test] fn after_child_table_dot_narrows_to_child_columns() { let schema = schema_multi_table(); - let a = assess_at_end( - "add 1:n relationship from Customers.id to Orders.", - &schema, - ); + let a = assess_at_end("add 1:n relationship from Customers.id to Orders.", &schema); assert!(matches!(a.state, InputState::IncompleteAtEof)); assert_candidate_present(&a, &["OrderId", "CustId", "Total"]); assert_no_candidate_named(&a, &["Name"]); @@ -101,10 +92,7 @@ fn add_relationship_with_on_delete_clause_parses() { #[test] fn add_relationship_in_progress_after_dot_is_incomplete() { let schema = schema_multi_table(); - let a = assess_at_end( - "add 1:n relationship from Customers.id to ", - &schema, - ); + let a = assess_at_end("add 1:n relationship from Customers.id to ", &schema); assert!(matches!(a.state, InputState::IncompleteAtEof)); crate::snap!("in_progress_after_to", a); } diff --git a/tests/typing_surface/create_m2n.rs b/tests/typing_surface/create_m2n.rs index 10abc06..e143a8f 100644 --- a/tests/typing_surface/create_m2n.rs +++ b/tests/typing_surface/create_m2n.rs @@ -67,7 +67,10 @@ fn create_m2n_with_as_name_parses() { #[test] fn after_as_keyword_is_incomplete() { let schema = schema_multi_table(); - let a = assess_at_end("create m:n relationship from Customers to Orders as ", &schema); + let a = assess_at_end( + "create m:n relationship from Customers to Orders as ", + &schema, + ); assert!(matches!(a.state, InputState::IncompleteAtEof)); crate::snap!("after_as", a); } diff --git a/tests/typing_surface/create_table.rs b/tests/typing_surface/create_table.rs index c6fb1c4..2c80e98 100644 --- a/tests/typing_surface/create_table.rs +++ b/tests/typing_surface/create_table.rs @@ -83,25 +83,16 @@ fn after_pk_space_with_col_name_typed_expects_paren() { #[test] fn after_paren_expects_type_candidates() { let schema = schema_empty(); - let a = assess_at_end( - "create table Customers with pk Code(", - &schema, - ); + let a = assess_at_end("create table Customers with pk Code(", &schema); assert!(matches!(a.state, InputState::IncompleteAtEof)); - assert_candidate_present( - &a, - &["text", "int", "serial", "shortid", "bool"], - ); + assert_candidate_present(&a, &["text", "int", "serial", "shortid", "bool"]); crate::snap!("after_paren", a); } #[test] fn create_table_with_explicit_pk_parses() { let schema = schema_empty(); - let a = assess_at_end( - "create table Customers with pk Code(text)", - &schema, - ); + let a = assess_at_end("create table Customers with pk Code(text)", &schema); assert!(matches!(a.state, InputState::Valid)); crate::snap!("with_explicit_pk", a); } diff --git a/tests/typing_surface/delete_with_where.rs b/tests/typing_surface/delete_with_where.rs index 000f623..90a0fdc 100644 --- a/tests/typing_surface/delete_with_where.rs +++ b/tests/typing_surface/delete_with_where.rs @@ -42,13 +42,8 @@ fn after_where_offers_active_table_columns_no_leakage() { #[test] fn after_where_column_equals_offers_typed_prose() { let schema = schema_serial_pk(); - let a = assess_at_end( - "delete from Customers where Email=", - &schema, - ); - let prose = hint_prose(&a).unwrap_or_else(|| { - panic!("expected Prose, got {:?}", a.hint) - }); + let a = assess_at_end("delete from Customers where Email=", &schema); + let prose = hint_prose(&a).unwrap_or_else(|| panic!("expected Prose, got {:?}", a.hint)); assert!( prose.contains("Email"), "should name `Email`, got prose: {prose:?}", @@ -63,10 +58,7 @@ fn after_where_column_equals_offers_typed_prose() { #[test] fn complete_delete_with_where_parses() { let schema = schema_serial_pk(); - let a = assess_at_end( - "delete from Customers where id=1", - &schema, - ); + let a = assess_at_end("delete from Customers where id=1", &schema); assert!(matches!(a.state, InputState::Valid)); assert_eq!(a.parse_result.as_deref(), Ok("Delete")); crate::snap!("complete_delete", a); @@ -76,9 +68,7 @@ fn complete_delete_with_where_parses() { fn delete_with_datetime_column_says_yyyy_mm_dd_t() { let schema = schema_every_type(); let a = assess_at_end("delete from Things where ts=", &schema); - let prose = hint_prose(&a).unwrap_or_else(|| { - panic!("expected Prose, got {:?}", a.hint) - }); + let prose = hint_prose(&a).unwrap_or_else(|| panic!("expected Prose, got {:?}", a.hint)); assert!( prose.contains("ts"), "should name `ts`, got prose: {prose:?}", diff --git a/tests/typing_surface/drop_column.rs b/tests/typing_surface/drop_column.rs index 68b5cc7..a3ce8ad 100644 --- a/tests/typing_surface/drop_column.rs +++ b/tests/typing_surface/drop_column.rs @@ -63,10 +63,7 @@ fn complete_drop_column_parses() { #[test] fn drop_column_with_table_keyword_parses() { let schema = schema_serial_pk(); - let a = assess_at_end( - "drop column from table Customers: Email", - &schema, - ); + let a = assess_at_end("drop column from table Customers: Email", &schema); assert!(matches!(a.state, InputState::Valid)); crate::snap!("with_table_keyword", a); } diff --git a/tests/typing_surface/drop_relationship.rs b/tests/typing_surface/drop_relationship.rs index 5f59a22..ffd8d30 100644 --- a/tests/typing_surface/drop_relationship.rs +++ b/tests/typing_surface/drop_relationship.rs @@ -30,10 +30,7 @@ fn after_relationship_keyword_offers_from_and_names() { assert!(matches!(a.state, InputState::IncompleteAtEof)); // Both selector forms discoverable: `from` for endpoints, // the relationship name for the by-name form. - assert_candidate_present( - &a, - &["from", "Orders_CustId_to_Customers"], - ); + assert_candidate_present(&a, &["from", "Orders_CustId_to_Customers"]); crate::snap!("after_relationship_keyword", a); } @@ -61,10 +58,7 @@ fn after_parent_table_dot_narrows_to_parent_columns() { #[test] fn after_to_offers_table_names() { let schema = schema_with_relationship(); - let a = assess_at_end( - "drop relationship from Orders.CustId to ", - &schema, - ); + let a = assess_at_end("drop relationship from Orders.CustId to ", &schema); assert!(matches!(a.state, InputState::IncompleteAtEof)); assert_candidate_present(&a, &["Customers", "Orders"]); crate::snap!("after_to", a); @@ -98,10 +92,7 @@ fn complete_drop_relationship_by_endpoints_parses() { #[test] fn complete_drop_relationship_by_name_parses() { let schema = schema_with_relationship(); - let a = assess_at_end( - "drop relationship Orders_CustId_to_Customers", - &schema, - ); + let a = assess_at_end("drop relationship Orders_CustId_to_Customers", &schema); assert!(matches!(a.state, InputState::Valid)); assert_eq!(a.parse_result.as_deref(), Ok("DropRelationship")); crate::snap!("complete_by_name", a); diff --git a/tests/typing_surface/explain.rs b/tests/typing_surface/explain.rs index dab465e..696fcf7 100644 --- a/tests/typing_surface/explain.rs +++ b/tests/typing_surface/explain.rs @@ -66,10 +66,7 @@ fn complete_explain_show_data_parses_as_explain() { #[test] fn complete_explain_show_data_with_where_and_limit_parses() { let schema = schema_serial_pk(); - let a = assess_at_end( - "explain show data Customers where id = 1 limit 5", - &schema, - ); + let a = assess_at_end("explain show data Customers where id = 1 limit 5", &schema); assert!(matches!(a.state, InputState::Valid)); assert_eq!(a.parse_result.as_deref(), Ok("Explain")); crate::snap!("complete_explain_show_data_where_limit", a); @@ -95,10 +92,7 @@ fn after_explain_update_table_expects_set() { #[test] fn complete_explain_update_parses_as_explain() { let schema = schema_serial_pk(); - let a = assess_at_end( - "explain update Customers set Name='Bo' where id=1", - &schema, - ); + let a = assess_at_end("explain update Customers set Name='Bo' where id=1", &schema); assert!(matches!(a.state, InputState::Valid)); assert_eq!(a.parse_result.as_deref(), Ok("Explain")); crate::snap!("complete_explain_update", a); diff --git a/tests/typing_surface/insert_form_a.rs b/tests/typing_surface/insert_form_a.rs index ce00ef2..2873966 100644 --- a/tests/typing_surface/insert_form_a.rs +++ b/tests/typing_surface/insert_form_a.rs @@ -143,10 +143,7 @@ fn after_values_keyword_expects_open_paren() { // Trailing space so we're past the `values` word boundary // — without it the partial-prefix logic re-offers `values` // itself as the candidate that matches the typed prefix. - let a = assess_at_end( - "insert into Customers (Name) values ", - &schema, - ); + let a = assess_at_end("insert into Customers (Name) values ", &schema); assert!(matches!(a.state, InputState::IncompleteAtEof)); assert_candidate_present(&a, &["("]); crate::snap!("after_values_keyword", a); @@ -159,10 +156,7 @@ fn after_values_keyword_expects_open_paren() { #[test] fn after_values_open_paren_form_a_text_column_prose_names_column() { let schema = schema_serial_pk(); - let a = assess_at_end( - "insert into Customers (Name) values (", - &schema, - ); + let a = assess_at_end("insert into Customers (Name) values (", &schema); assert!( hint_prose_contains(&a, "Name"), "expected column name in prose, got {:?}", @@ -179,13 +173,12 @@ fn after_values_open_paren_form_a_text_column_prose_names_column() { #[test] fn after_values_open_paren_form_a_serial_column_offers_null_to_auto_generate() { let schema = schema_serial_pk(); - let a = assess_at_end( - "insert into Customers (id, Name) values (", - &schema, + let a = assess_at_end("insert into Customers (id, Name) values (", &schema); + let prose = hint_prose(&a).unwrap_or_else(|| panic!("expected Prose hint, got {:?}", a.hint)); + assert!( + prose.contains("id"), + "prose should name `id`, got {prose:?}" ); - let prose = hint_prose(&a) - .unwrap_or_else(|| panic!("expected Prose hint, got {:?}", a.hint)); - assert!(prose.contains("id"), "prose should name `id`, got {prose:?}"); assert!( prose.contains("null") && prose.contains("auto-generate"), "prose should mention `null` to auto-generate, got {prose:?}", @@ -200,8 +193,7 @@ fn mid_value_list_after_comma_advances_to_next_column_prose() { "insert into Customers (Name, Email) values ('Alice', ", &schema, ); - let prose = hint_prose(&a) - .unwrap_or_else(|| panic!("expected Prose hint, got {:?}", a.hint)); + let prose = hint_prose(&a).unwrap_or_else(|| panic!("expected Prose hint, got {:?}", a.hint)); assert!( prose.contains("Email"), "prose should name `Email`, got {prose:?}", @@ -281,9 +273,8 @@ fn form_a_complete_with_serial_in_list_parses() { fn form_a_int_slot_prose_says_integer() { let schema = schema_every_type(); let a = assess_at_end("insert into Things (k) values (", &schema); - let prose = hint_prose(&a).unwrap_or_else(|| { - panic!("expected Prose for int slot, got {:?}", a.hint) - }); + let prose = + hint_prose(&a).unwrap_or_else(|| panic!("expected Prose for int slot, got {:?}", a.hint)); assert!( prose.contains("integer"), "int-slot prose should say `integer`, got {prose:?}", @@ -295,9 +286,8 @@ fn form_a_int_slot_prose_says_integer() { fn form_a_date_slot_prose_says_yyyy_mm_dd() { let schema = schema_every_type(); let a = assess_at_end("insert into Things (dt) values (", &schema); - let prose = hint_prose(&a).unwrap_or_else(|| { - panic!("expected Prose for date slot, got {:?}", a.hint) - }); + let prose = + hint_prose(&a).unwrap_or_else(|| panic!("expected Prose for date slot, got {:?}", a.hint)); assert!( prose.contains("YYYY-MM-DD"), "date-slot prose should reference YYYY-MM-DD format, got {prose:?}", @@ -309,9 +299,8 @@ fn form_a_date_slot_prose_says_yyyy_mm_dd() { fn form_a_bool_slot_prose_mentions_true_false() { let schema = schema_every_type(); let a = assess_at_end("insert into Things (b) values (", &schema); - let prose = hint_prose(&a).unwrap_or_else(|| { - panic!("expected Prose for bool slot, got {:?}", a.hint) - }); + let prose = + hint_prose(&a).unwrap_or_else(|| panic!("expected Prose for bool slot, got {:?}", a.hint)); assert!( prose.contains("true") && prose.contains("false"), "bool-slot prose should mention `true`/`false`, got {prose:?}", @@ -323,9 +312,8 @@ fn form_a_bool_slot_prose_mentions_true_false() { fn form_a_shortid_slot_prose_mentions_null_to_auto_generate() { let schema = schema_every_type(); let a = assess_at_end("insert into Things (sid) values (", &schema); - let prose = hint_prose(&a).unwrap_or_else(|| { - panic!("expected Prose for shortid slot, got {:?}", a.hint) - }); + let prose = hint_prose(&a) + .unwrap_or_else(|| panic!("expected Prose for shortid slot, got {:?}", a.hint)); assert!( prose.contains("null") && prose.contains("auto-generate"), "shortid-slot prose should mention `null` to auto-generate, got {prose:?}", diff --git a/tests/typing_surface/insert_form_b.rs b/tests/typing_surface/insert_form_b.rs index b994f6f..c453bd8 100644 --- a/tests/typing_surface/insert_form_b.rs +++ b/tests/typing_surface/insert_form_b.rs @@ -38,7 +38,10 @@ fn form_b_first_value_skips_serial_column() { let schema = schema_serial_pk(); let a = assess_at_end("insert into Customers values (", &schema); let prose = hint_prose(&a).unwrap_or_else(|| { - panic!("expected Prose at first Form B value slot, got {:?}", a.hint) + panic!( + "expected Prose at first Form B value slot, got {:?}", + a.hint + ) }); // The value slot itself must be keyed on `Name` — the first // non-auto column — not on the skipped `id`. @@ -60,9 +63,7 @@ fn form_b_first_value_skips_serial_column() { fn form_b_first_value_text_pk_names_first_column() { let schema = schema_text_pk(); let a = assess_at_end("insert into Items values (", &schema); - let prose = hint_prose(&a).unwrap_or_else(|| { - panic!("expected Prose, got {:?}", a.hint) - }); + let prose = hint_prose(&a).unwrap_or_else(|| panic!("expected Prose, got {:?}", a.hint)); assert!( prose.contains("Code"), "Form B should name the PK column `Code`, got prose: {prose:?}", @@ -76,9 +77,7 @@ fn form_b_first_value_every_type_first_column_is_int() { // must say `integer` and name `k`. let schema = schema_every_type(); let a = assess_at_end("insert into Things values (", &schema); - let prose = hint_prose(&a).unwrap_or_else(|| { - panic!("expected Prose, got {:?}", a.hint) - }); + let prose = hint_prose(&a).unwrap_or_else(|| panic!("expected Prose, got {:?}", a.hint)); assert!( prose.contains("k"), "should name column `k`, got prose: {prose:?}", @@ -98,13 +97,9 @@ fn form_b_first_value_every_type_first_column_is_int() { #[test] fn form_b_after_first_value_advances_to_next_column() { let schema = schema_serial_pk(); - let a = assess_at_end( - "insert into Customers values ('Alice', ", - &schema, - ); - let prose = hint_prose(&a).unwrap_or_else(|| { - panic!("expected Prose at second slot, got {:?}", a.hint) - }); + let a = assess_at_end("insert into Customers values ('Alice', ", &schema); + let prose = + hint_prose(&a).unwrap_or_else(|| panic!("expected Prose at second slot, got {:?}", a.hint)); assert!( prose.contains("Email"), "second slot should name `Email`, got prose: {prose:?}", @@ -121,10 +116,7 @@ fn form_b_after_first_value_advances_to_next_column() { #[test] fn form_b_in_progress_after_comma_is_incomplete() { let schema = schema_serial_pk(); - let a = assess_at_end( - "insert into Customers values ('Alice', ", - &schema, - ); + let a = assess_at_end("insert into Customers values ('Alice', ", &schema); assert!( matches!(a.state, InputState::IncompleteAtEof), "in-progress Form B should be Incomplete, got {:?}", @@ -136,10 +128,7 @@ fn form_b_in_progress_after_comma_is_incomplete() { #[test] fn form_b_in_progress_without_closing_paren_is_incomplete() { let schema = schema_serial_pk(); - let a = assess_at_end( - "insert into Customers values ('Alice', 'a@b.c'", - &schema, - ); + let a = assess_at_end("insert into Customers values ('Alice', 'a@b.c'", &schema); assert!(matches!(a.state, InputState::IncompleteAtEof)); crate::snap!("form_b_in_progress_no_close_paren", a); } @@ -201,10 +190,7 @@ fn form_b_with_extra_value_for_serial_column_is_invalid() { #[test] fn form_b_with_correct_values_parses() { let schema = schema_serial_pk(); - let a = assess_at_end( - "insert into Customers values ('Alice', 'a@b.c')", - &schema, - ); + let a = assess_at_end("insert into Customers values ('Alice', 'a@b.c')", &schema); assert!(matches!(a.state, InputState::Valid)); assert_eq!(a.parse_result.as_deref(), Ok("Insert")); crate::snap!("form_b_valid", a); @@ -213,10 +199,7 @@ fn form_b_with_correct_values_parses() { #[test] fn form_b_text_pk_with_correct_values_parses() { let schema = schema_text_pk(); - let a = assess_at_end( - "insert into Items values ('SKU-1', 'Widget')", - &schema, - ); + let a = assess_at_end("insert into Items values ('SKU-1', 'Widget')", &schema); assert!(matches!(a.state, InputState::Valid)); assert_eq!(a.parse_result.as_deref(), Ok("Insert")); crate::snap!("form_b_text_pk_valid", a); @@ -240,9 +223,8 @@ fn form_b_text_pk_with_correct_values_parses() { fn form_b_first_slot_mentions_skipped_serial_column() { let schema = schema_serial_pk(); let a = assess_at_end("insert into Customers values (", &schema); - let prose = hint_prose(&a).unwrap_or_else(|| { - panic!("expected Prose at first Form B slot, got {:?}", a.hint) - }); + let prose = hint_prose(&a) + .unwrap_or_else(|| panic!("expected Prose at first Form B slot, got {:?}", a.hint)); // Names the skipped auto-gen column. assert!( prose.contains("`id`"), @@ -261,13 +243,9 @@ fn form_b_second_slot_omits_skip_note() { // The note fires once, at the first slot only — not at // every comma. let schema = schema_serial_pk(); - let a = assess_at_end( - "insert into Customers values ('Alice', ", - &schema, - ); - let prose = hint_prose(&a).unwrap_or_else(|| { - panic!("expected Prose at second slot, got {:?}", a.hint) - }); + let a = assess_at_end("insert into Customers values ('Alice', ", &schema); + let prose = + hint_prose(&a).unwrap_or_else(|| panic!("expected Prose at second slot, got {:?}", a.hint)); assert!( !prose.contains("auto-generated"), "second-slot hint must NOT repeat the skip note, got: {prose:?}", @@ -280,9 +258,7 @@ fn form_b_text_pk_has_no_skip_note() { // No auto-gen columns → no skip note. let schema = schema_text_pk(); let a = assess_at_end("insert into Items values (", &schema); - let prose = hint_prose(&a).unwrap_or_else(|| { - panic!("expected Prose, got {:?}", a.hint) - }); + let prose = hint_prose(&a).unwrap_or_else(|| panic!("expected Prose, got {:?}", a.hint)); assert!( !prose.contains("auto-generated"), "text-PK table has no auto-gen column — no skip note expected, got: {prose:?}", @@ -295,13 +271,8 @@ fn form_a_first_slot_has_no_skip_note() { // Form A lists columns explicitly — the user is in control, // no pedagogical pointer needed. let schema = schema_serial_pk(); - let a = assess_at_end( - "insert into Customers (Name) values (", - &schema, - ); - let prose = hint_prose(&a).unwrap_or_else(|| { - panic!("expected Prose, got {:?}", a.hint) - }); + let a = assess_at_end("insert into Customers (Name) values (", &schema); + let prose = hint_prose(&a).unwrap_or_else(|| panic!("expected Prose, got {:?}", a.hint)); assert!( !prose.contains("auto-generated"), "Form A must not show the Form-B skip note, got: {prose:?}", @@ -315,9 +286,8 @@ fn form_b_advances_through_every_type_first_to_real() { // first value, prose must name `r` and say `number`. let schema = schema_every_type(); let a = assess_at_end("insert into Things values (1, ", &schema); - let prose = hint_prose(&a).unwrap_or_else(|| { - panic!("expected Prose at 2nd slot, got {:?}", a.hint) - }); + let prose = + hint_prose(&a).unwrap_or_else(|| panic!("expected Prose at 2nd slot, got {:?}", a.hint)); assert!(prose.contains("r"), "should name `r`, got prose: {prose:?}"); assert!( prose.contains("number"), diff --git a/tests/typing_surface/insert_form_c.rs b/tests/typing_surface/insert_form_c.rs index 1499b65..eb6e778 100644 --- a/tests/typing_surface/insert_form_c.rs +++ b/tests/typing_surface/insert_form_c.rs @@ -26,10 +26,7 @@ fn form_c_text_pk_correct_values_parses() { // Items(Code:text, Title:text) — Form C expects two text // values (no auto-gen columns to skip). let schema = schema_text_pk(); - let a = assess_at_end( - "insert into Items ('SKU-1', 'Widget')", - &schema, - ); + let a = assess_at_end("insert into Items ('SKU-1', 'Widget')", &schema); assert!(matches!(a.state, InputState::Valid)); assert_eq!(a.parse_result.as_deref(), Ok("Insert")); crate::snap!("form_c_text_pk_valid", a); @@ -40,10 +37,7 @@ fn form_c_serial_pk_correct_values_parses() { // Customers(id:serial, Name:text, Email:text) — Form C // skips the serial `id`, expects two text values. let schema = schema_serial_pk(); - let a = assess_at_end( - "insert into Customers ('Alice', 'a@b.c')", - &schema, - ); + let a = assess_at_end("insert into Customers ('Alice', 'a@b.c')", &schema); assert!(matches!(a.state, InputState::Valid)); assert_eq!(a.parse_result.as_deref(), Ok("Insert")); crate::snap!("form_c_serial_pk_valid", a); @@ -53,10 +47,7 @@ fn form_c_serial_pk_correct_values_parses() { fn form_c_with_null_value_parses() { // null is type-compatible with any slot. let schema = schema_serial_pk(); - let a = assess_at_end( - "insert into Customers (null, 'a@b.c')", - &schema, - ); + let a = assess_at_end("insert into Customers (null, 'a@b.c')", &schema); assert!(matches!(a.state, InputState::Valid)); crate::snap!("form_c_null_value", a); } @@ -71,10 +62,7 @@ fn form_c_rejects_number_for_text_column() { // rejects it at parse time. Before Form-C type-awareness // this parsed Valid and only failed at bind time. let schema = schema_serial_pk(); - let a = assess_at_end( - "insert into Customers (3.14, 'a@b.c')", - &schema, - ); + let a = assess_at_end("insert into Customers (3.14, 'a@b.c')", &schema); assert!( !matches!(a.state, InputState::Valid), "Form C should now type-check `3.14` against Name(text), got {:?}", @@ -114,13 +102,9 @@ fn form_c_second_slot_shows_typed_prose_for_column() { // First token `'Alice'` is a string literal → Form C. At // the second slot the hint names the Email column. let schema = schema_serial_pk(); - let a = assess_at_end( - "insert into Customers ('Alice', ", - &schema, - ); - let prose = hint_prose(&a).unwrap_or_else(|| { - panic!("expected Prose at Form C second slot, got {:?}", a.hint) - }); + let a = assess_at_end("insert into Customers ('Alice', ", &schema); + let prose = hint_prose(&a) + .unwrap_or_else(|| panic!("expected Prose at Form C second slot, got {:?}", a.hint)); assert!( prose.contains("Email"), "Form C second slot should name `Email`, got prose: {prose:?}", @@ -143,10 +127,7 @@ fn form_c_in_progress_after_comma_is_incomplete() { #[test] fn form_c_in_progress_without_close_paren_is_incomplete() { let schema = schema_serial_pk(); - let a = assess_at_end( - "insert into Customers ('Alice', 'a@b.c'", - &schema, - ); + let a = assess_at_end("insert into Customers ('Alice', 'a@b.c'", &schema); assert!(matches!(a.state, InputState::IncompleteAtEof)); crate::snap!("form_c_in_progress_no_close", a); } diff --git a/tests/typing_surface/mod.rs b/tests/typing_surface/mod.rs index bf4d88a..dae318e 100644 --- a/tests/typing_surface/mod.rs +++ b/tests/typing_surface/mod.rs @@ -22,25 +22,25 @@ use rdbms_playground::input_render::{ }; use rdbms_playground::mode::Mode; +pub mod add_relationship; +pub mod app_commands; +pub mod candidate_ordering; +pub mod constraints; +pub mod create_m2n; +pub mod create_table; +pub mod delete_all_rows; +pub mod delete_with_where; +pub mod drop_column; +pub mod drop_relationship; +pub mod explain; +pub mod index_ops; pub mod insert_form_a; pub mod insert_form_b; pub mod insert_form_c; -pub mod update_with_where; -pub mod update_all_rows; -pub mod delete_with_where; -pub mod where_expression; -pub mod delete_all_rows; -pub mod explain; -pub mod create_table; -pub mod drop_column; -pub mod drop_relationship; -pub mod add_relationship; -pub mod create_m2n; -pub mod index_ops; -pub mod constraints; pub mod rename_change_column; -pub mod app_commands; -pub mod candidate_ordering; +pub mod update_all_rows; +pub mod update_with_where; +pub mod where_expression; // ========================================================= // Canonical schema shapes (handoff §1 — CANONICAL_SCHEMA_SHAPES) @@ -81,10 +81,7 @@ pub fn schema_serial_pk() -> SchemaCache { /// Exercises the no-auto-gen path: Form A and Form B both /// require values for every column. pub fn schema_text_pk() -> SchemaCache { - build_schema(&[( - "Items", - &[("Code", Type::Text), ("Title", Type::Text)], - )]) + build_schema(&[("Items", &[("Code", Type::Text), ("Title", Type::Text)])]) } /// Two tables sharing no column names. @@ -96,10 +93,7 @@ pub fn schema_text_pk() -> SchemaCache { /// the other table. pub fn schema_multi_table() -> SchemaCache { build_schema(&[ - ( - "Customers", - &[("id", Type::Serial), ("Name", Type::Text)], - ), + ("Customers", &[("id", Type::Serial), ("Name", Type::Text)]), ( "Orders", &[ @@ -436,10 +430,7 @@ fn smoke_assess_at_end_returns_each_field() { #[test] fn smoke_assess_parse_label_round_trips() { let schema = schema_serial_pk(); - let a = assess_at_end( - "insert into Customers values ('Alice', 'a@b.c')", - &schema, - ); + let a = assess_at_end("insert into Customers values ('Alice', 'a@b.c')", &schema); assert_eq!(a.parse_result.as_deref(), Ok("Insert")); assert!(matches!(a.state, InputState::Valid)); } @@ -462,7 +453,11 @@ fn seed_completion_and_validity() { // Validity (ADR-0027): a known table seeds clean; an unknown one is // flagged (same table slot as update/delete/show data). let ok = assess_at_end("seed Customers 5", &schema); - assert!(matches!(ok.state, InputState::Valid), "known table: {:?}", ok.state); + assert!( + matches!(ok.state, InputState::Valid), + "known table: {:?}", + ok.state + ); // seed's unknown-table behaviour must match its closest sibling // `show data` (same table-only slot), whatever that is. let seed_ghost = assess_at_end("seed Ghost 5", &schema).state; diff --git a/tests/typing_surface/rename_change_column.rs b/tests/typing_surface/rename_change_column.rs index 4b8a809..699627f 100644 --- a/tests/typing_surface/rename_change_column.rs +++ b/tests/typing_surface/rename_change_column.rs @@ -34,10 +34,7 @@ fn rename_after_old_column_expects_to() { #[test] fn rename_after_to_is_new_name_slot() { let schema = schema_serial_pk(); - let a = assess_at_end( - "rename column in Customers: Email to ", - &schema, - ); + let a = assess_at_end("rename column in Customers: Email to ", &schema); assert!(matches!(a.state, InputState::IncompleteAtEof)); crate::snap!("rename_after_to", a); } @@ -45,10 +42,7 @@ fn rename_after_to_is_new_name_slot() { #[test] fn complete_rename_column_parses() { let schema = schema_serial_pk(); - let a = assess_at_end( - "rename column in Customers: Email to ContactEmail", - &schema, - ); + let a = assess_at_end("rename column in Customers: Email to ContactEmail", &schema); assert!(matches!(a.state, InputState::Valid)); assert_eq!(a.parse_result.as_deref(), Ok("RenameColumn")); crate::snap!("complete_rename", a); @@ -78,10 +72,7 @@ fn change_after_open_paren_offers_type_candidates() { #[test] fn complete_change_column_parses() { let schema = schema_serial_pk(); - let a = assess_at_end( - "change column in Customers: Email (text)", - &schema, - ); + let a = assess_at_end("change column in Customers: Email (text)", &schema); assert!(matches!(a.state, InputState::Valid)); assert_eq!(a.parse_result.as_deref(), Ok("ChangeColumnType")); crate::snap!("complete_change", a); diff --git a/tests/typing_surface/update_all_rows.rs b/tests/typing_surface/update_all_rows.rs index ffab249..deac580 100644 --- a/tests/typing_surface/update_all_rows.rs +++ b/tests/typing_surface/update_all_rows.rs @@ -7,10 +7,7 @@ use rdbms_playground::input_render::InputState; #[test] fn before_all_rows_flag_is_incomplete() { let schema = schema_serial_pk(); - let a = assess_at_end( - "update Customers set Email='x' ", - &schema, - ); + let a = assess_at_end("update Customers set Email='x' ", &schema); assert!(matches!(a.state, InputState::IncompleteAtEof)); // Both `where` and `--all-rows` are valid continuations. assert_candidate_present(&a, &["--all-rows"]); @@ -20,10 +17,7 @@ fn before_all_rows_flag_is_incomplete() { #[test] fn complete_update_all_rows_parses() { let schema = schema_serial_pk(); - let a = assess_at_end( - "update Customers set Email='new@b.c' --all-rows", - &schema, - ); + let a = assess_at_end("update Customers set Email='new@b.c' --all-rows", &schema); assert!(matches!(a.state, InputState::Valid)); assert_eq!(a.parse_result.as_deref(), Ok("Update")); crate::snap!("complete_all_rows", a); @@ -33,10 +27,7 @@ fn complete_update_all_rows_parses() { fn update_without_filter_clause_is_incomplete() { // Per ADR-0014, update requires WHERE or --all-rows. let schema = schema_serial_pk(); - let a = assess_at_end( - "update Customers set Email='new@b.c'", - &schema, - ); + let a = assess_at_end("update Customers set Email='new@b.c'", &schema); assert!(matches!(a.state, InputState::IncompleteAtEof)); crate::snap!("no_filter_clause", a); } @@ -44,10 +35,7 @@ fn update_without_filter_clause_is_incomplete() { #[test] fn update_partial_flag_name_is_incomplete() { let schema = schema_serial_pk(); - let a = assess_at_end( - "update Customers set Email='x' --all", - &schema, - ); + let a = assess_at_end("update Customers set Email='x' --all", &schema); // Partial flag still in progress. assert!(!matches!(a.state, InputState::Valid)); crate::snap!("partial_flag", a); diff --git a/tests/typing_surface/update_with_where.rs b/tests/typing_surface/update_with_where.rs index efeb8b9..727ba85 100644 --- a/tests/typing_surface/update_with_where.rs +++ b/tests/typing_surface/update_with_where.rs @@ -64,9 +64,7 @@ fn after_set_column_expects_equals() { fn after_equals_offers_typed_slot_prose_for_column() { let schema = schema_serial_pk(); let a = assess_at_end("update Customers set Email=", &schema); - let prose = hint_prose(&a).unwrap_or_else(|| { - panic!("expected Prose, got {:?}", a.hint) - }); + let prose = hint_prose(&a).unwrap_or_else(|| panic!("expected Prose, got {:?}", a.hint)); assert!( prose.contains("Email"), "should name `Email`, got prose: {prose:?}", @@ -82,9 +80,7 @@ fn after_equals_offers_typed_slot_prose_for_column() { fn after_equals_for_date_column_says_yyyy_mm_dd() { let schema = schema_every_type(); let a = assess_at_end("update Things set dt=", &schema); - let prose = hint_prose(&a).unwrap_or_else(|| { - panic!("expected Prose, got {:?}", a.hint) - }); + let prose = hint_prose(&a).unwrap_or_else(|| panic!("expected Prose, got {:?}", a.hint)); assert!( prose.contains("YYYY-MM-DD"), "date-slot prose should reference YYYY-MM-DD, got {prose:?}", @@ -95,10 +91,7 @@ fn after_equals_for_date_column_says_yyyy_mm_dd() { #[test] fn mid_assignment_list_after_comma_offers_remaining_columns() { let schema = schema_multi_table(); - let a = assess_at_end( - "update Customers set Name='x', ", - &schema, - ); + let a = assess_at_end("update Customers set Name='x', ", &schema); assert!(matches!(a.state, InputState::IncompleteAtEof)); // Customers columns offered, no leakage. assert_candidate_present(&a, &["id"]); @@ -109,10 +102,7 @@ fn mid_assignment_list_after_comma_offers_remaining_columns() { #[test] fn after_assignments_expects_where_or_all_rows() { let schema = schema_serial_pk(); - let a = assess_at_end( - "update Customers set Email='new@b.c' ", - &schema, - ); + let a = assess_at_end("update Customers set Email='new@b.c' ", &schema); assert!(matches!(a.state, InputState::IncompleteAtEof)); assert_candidate_present(&a, &["where", "--all-rows"]); crate::snap!("after_assignments", a); @@ -125,10 +115,7 @@ fn after_assignments_expects_where_or_all_rows() { #[test] fn after_where_keyword_offers_active_table_columns() { let schema = schema_multi_table(); - let a = assess_at_end( - "update Customers set Name='x' where ", - &schema, - ); + let a = assess_at_end("update Customers set Name='x' where ", &schema); assert!(matches!(a.state, InputState::IncompleteAtEof)); assert_candidate_present(&a, &["id", "Name"]); assert_no_candidate_named(&a, &["OrderId", "CustId", "Total"]); @@ -138,13 +125,8 @@ fn after_where_keyword_offers_active_table_columns() { #[test] fn after_where_column_equals_offers_typed_prose() { let schema = schema_serial_pk(); - let a = assess_at_end( - "update Customers set Email='x' where id=", - &schema, - ); - let prose = hint_prose(&a).unwrap_or_else(|| { - panic!("expected Prose, got {:?}", a.hint) - }); + let a = assess_at_end("update Customers set Email='x' where id=", &schema); + let prose = hint_prose(&a).unwrap_or_else(|| panic!("expected Prose, got {:?}", a.hint)); assert!( prose.contains("id"), "should name where column `id`, got prose: {prose:?}", @@ -159,10 +141,7 @@ fn after_where_column_equals_offers_typed_prose() { #[test] fn complete_update_with_where_parses() { let schema = schema_serial_pk(); - let a = assess_at_end( - "update Customers set Email='new@b.c' where id=1", - &schema, - ); + let a = assess_at_end("update Customers set Email='new@b.c' where id=1", &schema); assert!(matches!(a.state, InputState::Valid)); assert_eq!(a.parse_result.as_deref(), Ok("Update")); crate::snap!("complete_update", a); diff --git a/tests/typing_surface/where_expression.rs b/tests/typing_surface/where_expression.rs index 4725c92..627b5c5 100644 --- a/tests/typing_surface/where_expression.rs +++ b/tests/typing_surface/where_expression.rs @@ -95,10 +95,7 @@ fn show_data_after_where_predicate_offers_limit() { #[test] fn complete_show_data_with_where_and_limit_parses() { let schema = schema_serial_pk(); - let a = assess_at_end( - "show data Customers where id=1 limit 10", - &schema, - ); + let a = assess_at_end("show data Customers where id=1 limit 10", &schema); assert!(matches!(a.state, InputState::Valid)); assert_eq!(a.parse_result.as_deref(), Ok("ShowData")); crate::snap!("complete_show_where_limit", a); From ec3c7c304c40a91663454a2a8a9b357aac8f728e Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Wed, 17 Jun 2026 21:40:58 +0000 Subject: [PATCH 08/20] ci: enable the cargo fmt --check gate (ADR-ci-002 Amendment 1) Adds `cargo fmt --check` (stock defaults) to ci.yaml's gate, now that the tree is rustfmt-clean (commit 41b7e9a). Records that reformat in .git-blame-ignore-revs so `git blame` skips it. Amends ADR-ci-002 (the deferred "revisit on main" fmt decision) + the ci ADR index. Closes #35. --- .git-blame-ignore-revs | 7 +++++++ .gitea/workflows/ci.yaml | 9 ++++++--- docs/ci/adr/20260612-adr-ci-002.md | 10 ++++++++++ docs/ci/adr/README.md | 2 +- 4 files changed, 24 insertions(+), 4 deletions(-) create mode 100644 .git-blame-ignore-revs diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 0000000..ce79e62 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,7 @@ +# Revisions to ignore in `git blame` — bulk, mechanical, no-behaviour-change +# commits whose authorship is noise. Enable locally with: +# git config blame.ignoreRevsFile .git-blame-ignore-revs +# (Forges that support it, e.g. GitHub, pick this up automatically.) + +# style: format the whole tree with cargo fmt (stock defaults, #35) +41b7e9a04992cd9708f1775b57044de838b48b85 diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index f2cc6a7..232f564 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -2,9 +2,10 @@ # 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. +# Gate = fmt + clippy + test. The fmt gate (`cargo fmt --check`, stock defaults) +# was enabled once the tree was reformatted on main (ADR-ci-002 Amendment 1 / +# issue #35). The release job (static binary for D2) and the platform matrix +# layer on later, step by step. name: ci on: push: @@ -39,6 +40,8 @@ jobs: image: git.lazyeval.net/oli/rdbms-playground-ci:latest steps: - uses: actions/checkout@v4 + - name: fmt (check, stock defaults) + run: nix develop -c cargo fmt --check - name: clippy (warnings denied) run: nix develop -c cargo clippy --all-targets -- -D warnings - name: test diff --git a/docs/ci/adr/20260612-adr-ci-002.md b/docs/ci/adr/20260612-adr-ci-002.md index 6976b1f..4e7f7f8 100644 --- a/docs/ci/adr/20260612-adr-ci-002.md +++ b/docs/ci/adr/20260612-adr-ci-002.md @@ -133,3 +133,13 @@ declaration of the dev *and* build environment. flake for `requirements.md` **TT5** (CI runs the tiers) and the **D1/D2/D3** distribution items (the release uses a static musl target built through this flake). + +## Amendment 1 — 2026-06-17: `fmt` gate enabled (issue #35) + +The deferred "revisit on `main`" is done. With the CI + website branches +merged and before the first public release, the tree was reformatted once +with **stock `cargo fmt`** (no `rustfmt.toml` — stable rustfmt supports no +meaningful customisation, and the pinned 1.95.0 toolchain makes +`fmt --check` deterministic) in a single mechanical commit (`41b7e9a`, +102 files, behaviour-preserving; recorded in `.git-blame-ignore-revs`). +`ci.yaml`'s gate is now **`fmt --check` + clippy + test**. Closes **#35**. diff --git a/docs/ci/adr/README.md b/docs/ci/adr/README.md index 7d1136b..251331b 100644 --- a/docs/ci/adr/README.md +++ b/docs/ci/adr/README.md @@ -19,5 +19,5 @@ 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-002 — Nix flake for a reproducible dev + build environment](20260612-adr-ci-002.md) — **Accepted 2026-06-12** (relocated from main's **ADR-0049** on the same day — content unchanged — to keep CI/dev-env decisions out of `main`'s integer sequence). The single, version-pinned declaration of the **dev *and* build toolchain** so CI never relies on whatever Rust is on the build machine — mirroring **datamage ADR 0046**, but far simpler (pure-Rust TUI). Root **Nix flake** with two outputs: **`devShells.default`** (pinned **Rust 1.95.0** via `rust-toolchain.toml` + `rust-overlay`, `cargo-sweep`, and the musl cc for the static release build) and **`packages.default`** (`rustPlatform.buildRustPackage` from the committed `Cargo.lock`; `doCheck = false`). Exact-pin (not floating `stable`) so `nix flake update` can't surprise-bump clippy past the `-D warnings` gate. System inputs near-empty by design (`libsqlite3-sys bundled` → stdenv cc only; `arboard`→`x11rb` pure-Rust). `.envrc` (`use flake`) for direnv parity. Verified through the flake: `nix build` yields a working binary, clippy clean, **2424 tests pass / 0 fail / 1 intentional ignored doctest**. Consumed by **ADR-ci-001** (the pipeline). Alternatives rejected: dev-shell-only; a standard `rust:1.95` CI image (a second toolchain definition = drift); `rustup` on the build host (non-reproducible). **Amendment 1 (2026-06-17, issue #35):** the deferred `fmt` gate is enabled — the tree was reformatted once with **stock `cargo fmt`** (no `rustfmt.toml`; pinned toolchain makes `fmt --check` deterministic) in a single mechanical commit (`41b7e9a`, 102 files, behaviour-preserving, in `.git-blame-ignore-revs`), and `ci.yaml`'s gate is now **`fmt --check` + clippy + test**. - [ADR-ci-003 — Cross-platform release builds (the D1 matrix)](20260613-adr-ci-003.md) — **Accepted 2026-06-13** (implemented + a real matrix release verified the same day — tag `v.0.0.0-citest3` published 8 assets). Cross-compiles the **four non-macOS D1 targets** from the Linux x86_64 runner with **`cargo-zigbuild`** (Zig's bundled clang + libc as one universal cross cc/linker, incl. rusqlite's bundled SQLite C; added to the flake devShell, replacing the single-target musl cc): **`x86_64`/`aarch64-unknown-linux-musl`** (musl + crt-static → fully static, **D2**) and **`x86_64-pc-windows-gnu`** / **`aarch64-pc-windows-gnullvm`** (Zig statically links libc → standalone `.exe`). **Windows `synchronization` stub:** Rust std links `-lsynchronization` (WaitOnAddress thread-parking), an import lib rust-overlay's toolchain doesn't ship and Zig's mingw lacks; the symbols are forwarded by `kernel32`, so an **empty 8-byte stub** `libsynchronization.a` (`ci/winstub/`, wired via `.cargo/config.toml` for the Windows targets only) satisfies the linker. **Workflow:** `release.yaml` = **`test` once (host) → `build` matrix** over the four targets (`needs: test`, `fail-fast: false`); each job packages binary (`.exe` for Windows) + `.sha256` and uploads to the **shared release** via idempotent create-or-get. **macOS** (2026-06-14 amendment) — built natively on a **Tart (Apple-Silicon) runner** (`runs-on: macos`), which makes the SDK fully licensed and dissolves the grey-area/public-image problem; `release-macos.yaml` is **dispatch-only** (intermittent runner), de-nixes the binary's libiconv load path (`install_name_tool` → `/usr/lib`) + re-signs ad-hoc, and uploads to the tagged release. **D1 complete (all six targets).** Alternatives rejected: `cross` (no macOS, needs DinD), per-target nix cross (Windows-aarch64 unpackaged, macOS-from-Linux unsupported), a real `libsynchronization.a` (more machinery, doesn't cover Windows-aarch64). **Amendment 2 (2026-06-16):** CI is **merged to `main`**, so `release-macos` is now triggerable (`workflow_dispatch` is default-branch-only) and has been **dispatched + verified end-to-end** (build → de-nix/re-sign → upload, binaries launch). Runtime-verified by the user: Linux x86_64, Windows aarch64, **and both macOS targets**; Linux aarch64 + Windows x86_64 are the outstanding runtime checks. Builds on ADR-ci-002 (flake) and fills in ADR-ci-001 §3 (Release). From 88830ed06ae1de7a849feecb61e8b071aa75a244 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Wed, 17 Jun 2026 21:46:52 +0000 Subject: [PATCH 09/20] chore(release): bump version to 0.2.0 First release carrying the version surfaces (--version / -V / the in-app version command, ADR-0054), the curl|sh + PowerShell installers (ADR-0055), crates.io/binstall readiness (ADR-0056), the verified hint corpus, the Ctrl-G demo F1 alias, and the cargo fmt gate (#35). The release guard checks this equals the v0.2.0 tag. --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c1fc106..66411c9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1535,7 +1535,7 @@ dependencies = [ [[package]] name = "rdbms-playground" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "arboard", diff --git a/Cargo.toml b/Cargo.toml index 92aa8f6..822d762 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rdbms-playground" -version = "0.1.0" +version = "0.2.0" edition = "2024" description = "A cross-platform TUI playground for learning relational databases." license = "MIT OR Apache-2.0" From bd5be5ecc7b78988a1bceaf697765238f42753fe Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Wed, 17 Jun 2026 21:58:32 +0000 Subject: [PATCH 10/20] fix(ci): read release version from Cargo.toml, not cargo metadata (ADR-0054) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ADR-0054 version guard piped `nix develop -c cargo metadata` to node, but the flake devShell prints a banner to stdout — corrupting the JSON pipe, so the guard aborted under `set -e` and the v0.2.0 release failed there (before building anything). Replace it with a toolchain-free `grep -m1 '^version = ' Cargo.toml` (the anchor excludes dependency `version =` keys). No real version mismatch occurred — the tagged commit has version 0.2.0. --- .gitea/workflows/release.yaml | 17 +++++++++++------ ...4-release-versioning-and-version-surfaces.md | 4 +++- docs/adr/README.md | 2 +- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml index 59ad49f..348b214 100644 --- a/.gitea/workflows/release.yaml +++ b/.gitea/workflows/release.yaml @@ -33,12 +33,17 @@ jobs: TAG: ${{ github.ref_name }} run: | set -euo pipefail - # CARGO_PKG_VERSION is the single source of truth; the binary reports - # it via --version / the `version` command. Parse it from cargo - # metadata (node is in the CI image; avoids assuming jq). - VER=$(nix develop -c cargo metadata --no-deps --format-version 1 \ - | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>process.stdout.write(JSON.parse(s).packages[0].version))') - echo "tag=$TAG cargo=$VER" + # Read the [package] version straight from Cargo.toml — toolchain-free + # and robust. (An earlier `nix develop -c cargo metadata | node` version + # broke: the flake devShell prints a banner to stdout, corrupting the + # JSON pipe.) `^version = ` is anchored, so it matches only the package + # version, never the `version = ` inside dependency inline tables. + VER=$(grep -m1 '^version = ' Cargo.toml | sed -E 's/^version = "(.*)"/\1/') + echo "tag=$TAG cargo=v$VER" + if [ -z "$VER" ]; then + echo "ERROR: could not read the package version from Cargo.toml" >&2 + exit 1 + fi if [ "$TAG" != "v$VER" ]; then echo "ERROR: release tag '$TAG' != 'v$VER' (Cargo.toml). Bump Cargo.toml to the release version, commit, then retag (ADR-0054)." >&2 exit 1 diff --git a/docs/adr/0054-release-versioning-and-version-surfaces.md b/docs/adr/0054-release-versioning-and-version-surfaces.md index c2a0378..eb95019 100644 --- a/docs/adr/0054-release-versioning-and-version-surfaces.md +++ b/docs/adr/0054-release-versioning-and-version-surfaces.md @@ -37,7 +37,9 @@ version I'm running" — that drift is a correctness problem. one rendered string and one version source. 3. **Release-CI discipline.** `release.yaml`'s pre-build `test` job gains a - **version guard**: it parses `CARGO_PKG_VERSION` from `cargo metadata` + **version guard**: it reads the `[package]` version directly from + `Cargo.toml` (`grep -m1 '^version = '` — toolchain-free; an earlier + `cargo metadata | node` form broke on the flake devShell's stdout banner) and **fails the release** unless the pushed tag equals `v`. So `--version`, the release name, and the downloaded asset are always in lockstep — enforced by the machine, not by memory. diff --git a/docs/adr/README.md b/docs/adr/README.md index 213c4de..2fb4509 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -66,6 +66,6 @@ This directory contains the project's ADRs, recorded per - [ADR-0051 — Bottom keybinding strip: context- and state-aware](0051-context-state-aware-keybinding-strip.md) — **Accepted + implemented 2026-06-13 (issue #27)**, closes Gitea **#27**. Repurposes the bottom status line into a **keystrokes-only, state-selected** strip (builds on ADR-0046 nav focus, ADR-0003 modes, ADR-0049 the #29 readline keys it now advertises, ADR-0022 the completion memo). A pure `status_bar_bindings(app) -> Vec<(key,label)>` chooses the strip by **priority, first match wins**: (1) **sidebar focus** → `Ctrl-O next pane · ↑↓/PgUp/PgDn scroll · Esc input`; (2) **completion memo live** (`last_completion`) → `Tab/Shift-Tab cycle · Esc cancel · Enter run`; (3) **history navigation** (new `App::is_browsing_history()` exposing the private `history_cursor`) → `↑↓ browse · Esc clear · Enter run`; (4) **editing** (input non-empty) → `Esc clear · Ctrl-A/E home/end · Ctrl-W del word · Enter run` (surfaces the #29 keys, closing ADR-0049's deferred advertisement); (5) **default** (empty) → `Ctrl-O sidebar · Tab complete · ↑ history · Enter run`. Priority is correct because Up clears the completion memo and Tab cancels history nav, so states 2/3 never co-occur, and the five are exhaustive for Input focus. **Typed-command words leave the strip** (`mode advanced`/`mode simple` switch, `:` one-shot) and **mode discovery moves to the empty-input hint** (`resolve_hint_lines`), **simple mode only**: `\`mode advanced\` for SQL` (the verb "type" omitted — the prompt implies it; advanced mode shows **no** pointer per a post-trial user decision — a switcher knows how they got there and `help` covers the way back). The one-shot's old `Backspace cancel one-shot` label is subsumed by the editing state (behaviour intact). Forks all user-chosen: **editing state shows the #29 keys** (vs unadvertised); **`Ctrl-C quit` omitted** from the strip (vs always shown); **no width-drop machinery** — the longest strip (~65 cols) fits all supported widths, so a **width-budget unit test** keeps it lean by construction instead (the user's own observation). Catalog: 12 new `shortcut.*` labels + the `panel.hint_mode_advanced` string added to `en-US.yaml`+`keys.rs` (validator-checked 1:1), 5 now-dead strip strings removed. **Modal-aware strip is OOS** (pre-existing: a modal owns the keyboard and carries its own hints; the strip under it is unchanged-in-kind, not worsened). Tests: 9 Tier-1 unit (per-state key sets — completion/history driven through real key events; width budget; mode-pointer presence/absence), 1 Tier-3 rewritten (`status_bar_is_keystroke_only_and_state_aware`), 15 full-panel snapshots re-accepted (reviewed — strip/hint only). **2467 pass / 0 fail / 0 skip (1 ignored), clippy clean.** OOS: modal-aware strip; a full-key cheatsheet overlay; Ctrl-K/U advertisement (editing strip shows the highest-value subset within the width budget) - [ADR-0052 — Mode-tagged history for cross-mode recall](0052-mode-tagged-history-cross-mode-recall.md) — **Accepted + implemented 2026-06-13 (issue #30)**, closes Gitea **#30** — the feature (advanced history reusable in simple mode) **and** the bug in its comment (the `:` one-shot prefix lost across sessions). **Amends ADR-0034** (status field gains a `:adv` tag; **journaling moves from the worker to the dispatch layer**), **ADR-0015 §5/§6** (history.log leaves the worker transaction — `commit-db-last` now scopes yaml/csv/db only), and **ADR-0040** (a success-path journal-write failure is best-effort, not fatal); references ADR-0003. **Root cause:** history carried no mode, and the in-memory ring stored the raw `:select 1` while the worker journalled the *stripped* `select 1`, so the `:` was lost on disk. **Fix:** record the submission mode per entry as a **`:adv` suffix on the status token** (`ok`/`ok:adv`/`err`/`err:adv`) — `source` stays last + canonical so replay is unaffected; the in-memory ring (still `Vec`) stores advanced entries in their `: `-prefixed simple-mode runnable form (a leading `:` unambiguously marks advanced since simple DSL never starts with `:`); recall **strips the `:` in advanced mode** (runs as bare SQL) and keeps it in simple mode (runs via the one-shot escape); hydration reconstructs the `: `-prefix from the tag, so cross-session = in-session. **The architectural turn (user's call):** the first draft kept journaling in the worker + threaded the mode down (~30-site plumbing); on review the user asked why the journal is written deep in the worker when the *failure* path already journals at the top of the chain — it shouldn't (history.log is a journal, not state). So **success journaling moved up** to `spawn_dsl_dispatch` / `run_replay` / the app-command sites (next to the failure path), the worker's `finalize_persistence` now writes only yaml/csv, and the journal write became **best-effort** (the command is already committed — consistent with the failure path; a rare disk-full leaves a committed command unjournalled, state intact). **App commands** journal simple (dispatched outside the spawn) and `submit` excludes them from the ring's advanced flag, so `undo`/`mode advanced` recall bare. Forks user-chosen: status-tag format (vs 4th field / `:`-in-source); unified scope; **dispatch-layer best-effort journaling** (vs worker-coupled-fatal). Two `/runda` passes (the second drove the relocation + app-command exclusion). Tests: the 15 worker-level journaling tests retired (worker no longer journals — yaml/csv/operation checks kept), re-covered at the new layer (history.rs status-tag + `:`-reconstruct; app.rs recall matrix; the #30 cross-session regression in `iteration6`; replay tests cover `run_replay` journaling). **2471 pass / 0 fail / 0 skip (1 ignored), clippy clean.** replay re-journaling mode-fidelity (a replayed advanced line re-journals simple — not a regression). **Follow-up done 2026-06-14:** the vestigial worker `source` plumbing was fully unwound (compiler-guided, no behaviour change) — `_source` removed from `finalize_persistence`/`do_rebuild_from_text`, the three `*_request` wrappers inlined+deleted, the dead `source` param dropped from the ~30 forwarding worker handlers, and the `source` field removed from the `DescribeTable`/`QueryData`/`RunSelect` requests + their `DatabaseHandle` methods (~164 mostly-test call sites); the only worker `source` left is the snapshot/undo label (see ADR-0052 *Consequences*) - [ADR-0053 — Contextual `hint` command and keybinding](0053-contextual-hint-command-and-keybinding.md) — **Accepted, implemented 2026-06-15** (Phases A–D; closes **A1** + requirements **H2**). Settles the `hint` slot ADR-0003 left "ADR pending"; closes the last open piece of **A1** and tracks requirements **H2**. **Two surfaces:** an **F1 keybinding** that renders a deep hint for the *live* partial input without submitting (the primary path — a submitted `hint` command can't see the buffer it would help with, since Enter empties it), and a submitted **`hint` command** that expands on the *most recent error*. **No topic argument** (contextual only — `help ` already owns explicit reference). Introduces a **tier-3 teaching layer**, deeper than the existing tier-1 (colour / error headline) and tier-2 (ambient one-liner; and the error `hint:`, which is shown **by default** since `Verbosity::Verbose` is the default — `messages short` is the opt-*out*); without it `hint` would just duplicate what's already on screen. Tier-3 content lives in the catalogue under `hint.cmd.` (per command form) and `hint.err.` (per error/diagnostic class), each a structured `what`/`example`/`concept` block rendered via a new `note_hint*` family with `OutputStyleClass::Hint`. **Keyed per-form via a new `hint_ids: &[&str]` field on `CommandNode` mirroring `usage_ids`** (revised in Phase B): a per-*node* key proved too coarse — `add`/`drop`/`show`/`create` are each one node spanning many forms, and a live-input hint for `add 1:n relationship` must be specific to relationships; `hint_key_for_input_in_mode` reuses `usage_key_for_input_in_mode`'s form-word disambiguation, and covers the advanced-SQL forms whose `usage_ids` are empty. Not keyed off `help_id` (it is `None` on the advanced-SQL nodes purely to dedup the `help` list; that parallel gap is issue **#36**). **Clause-concept hints** (`on delete` actions, constraint slots, `with pk`, cardinality) are a recorded **deferred extension** (`hint.concept.`, issue **#37**) — per-form is the right tier-3 granularity, with position-awareness owned by tier-2 + the live `Next:` line. Runtime `translate_error` classes resolve via stored `last_error_hint_key` (`hint` command / empty-F1). (The second route — pre-submit `diagnostic.*` read live from the walker on the F1 path — is **deferred**, issue **#38**: `Diagnostic` carries no class key.) Adds `AppCommand::Hint`, a `HINT` grammar node + REGISTRY entry, the `hint_ids` field, and `last_error_hint_key`; F1 is a read-only overlay (buffer + completion memo untouched). **Content is the bulk of the work** (the mechanism is ~a day): v1 scope = ~37 command forms + 9 runtime error classes (comprehensive for those, ~57 blocks), authored **exemplars-first** (voice approved in this ADR's `/runda` review, then mass-authored in batches), enforced by a **comprehensiveness coverage test**, with graceful fall-back to tier-2 if a key is ever missing. The **pre-submit-diagnostic route + ~33 `diagnostic.*` blocks were deferred** (issue **#38**) — `Diagnostic` carries no class key, so the route needs a broad change for marginal value (tier-2 already surfaces diagnostics; many duplicate runtime classes). Forks user-chosen: two-surface model; **F1** (vs `?` / a chord); no-arg; comprehensive-for-commands-and-errors scope; exemplars-first; diagnostics deferred. OOS: per-topic `hint ` (rejected — overlaps `help`); always-on tier-3 (rejected — keeps ambient terse); non-`en-US` locales + success-command teaching (deferred); clause-concept hints (issue #37); the diagnostic route (issue #38); the `help`-side advanced-SQL gap (issue #36) -- [ADR-0054 — Release versioning policy + version surfaces (`--version` / `version`)](0054-release-versioning-and-version-surfaces.md) — **Accepted + implemented 2026-06-16** (plan: `docs/plans/20260616-public-availability.md`, step 1 on the road to public availability; no prior issue/`requirements.md` item — an untracked gap). Fixes the **tag↔crate-version decoupling**: `Cargo.toml` built `0.1.0` while `release.yaml` named assets from the git tag, so a binary could report a version different from the asset it shipped in. **Decision:** `Cargo.toml` `version` is the **single source of truth** (read via `env!("CARGO_PKG_VERSION")`, no tag-injection); two surfaces report it through one `cli::version_text()` → catalog `cli.version_line` — a **`--version` / `-V`** CLI flag (mirrors `--help`, prints+exits in `main.rs`) and an in-app **`version`** command (REGISTRY node `app::VERSION`, `AppCommand::Version`, emits via `note_system`); and a **release-CI version guard** (`release.yaml` `test` job parses `cargo metadata` and **fails the release** unless the `v*` tag equals `v`). Release ritual: bump `Cargo.toml` → commit → tag → push. New keys `cli.version_line` + `help.app.version` + `parse.usage.version` + `hint.cmd.version.{what,example}` (the new REGISTRY command pulls in the comprehensiveness coverage gate). Rejected: tag-as-source (makes Cargo.toml lie). Deferred: git-hash/build-date enrichment (behind the same `version_text()` seam); UI placement beyond the command. Tested test-first: CLI parse (`--version`/`-V`/default-off), `version_text()` carries `CARGO_PKG_VERSION`, the in-app command parses + emits. Also corrected a stale `release.yaml` header comment ("macOS is deferred" → built by the dispatched `release-macos.yaml`). +- [ADR-0054 — Release versioning policy + version surfaces (`--version` / `version`)](0054-release-versioning-and-version-surfaces.md) — **Accepted + implemented 2026-06-16** (plan: `docs/plans/20260616-public-availability.md`, step 1 on the road to public availability; no prior issue/`requirements.md` item — an untracked gap). Fixes the **tag↔crate-version decoupling**: `Cargo.toml` built `0.1.0` while `release.yaml` named assets from the git tag, so a binary could report a version different from the asset it shipped in. **Decision:** `Cargo.toml` `version` is the **single source of truth** (read via `env!("CARGO_PKG_VERSION")`, no tag-injection); two surfaces report it through one `cli::version_text()` → catalog `cli.version_line` — a **`--version` / `-V`** CLI flag (mirrors `--help`, prints+exits in `main.rs`) and an in-app **`version`** command (REGISTRY node `app::VERSION`, `AppCommand::Version`, emits via `note_system`); and a **release-CI version guard** (`release.yaml` `test` job reads the `[package]` version from `Cargo.toml` and **fails the release** unless the `v*` tag equals `v`; the guard's parse was later switched from `cargo metadata | node` to a `grep` on Cargo.toml after the former broke on the flake devShell's stdout banner). Release ritual: bump `Cargo.toml` → commit → tag → push. New keys `cli.version_line` + `help.app.version` + `parse.usage.version` + `hint.cmd.version.{what,example}` (the new REGISTRY command pulls in the comprehensiveness coverage gate). Rejected: tag-as-source (makes Cargo.toml lie). Deferred: git-hash/build-date enrichment (behind the same `version_text()` seam); UI placement beyond the command. Tested test-first: CLI parse (`--version`/`-V`/default-off), `version_text()` carries `CARGO_PKG_VERSION`, the in-app command parses + emits. Also corrected a stale `release.yaml` header comment ("macOS is deferred" → built by the dispatched `release-macos.yaml`). - [ADR-0055 — `curl | sh` install script (`scripts/install.sh`)](0055-curl-sh-install-script.md) — **Accepted + implemented 2026-06-17** (plan: `docs/plans/20260616-public-availability.md`, step 2; tracked by plan + ADR, no Gitea issue — user decision). A one-line installer (`curl -fsSL /scripts/install.sh | sh`) so beginners don't hand-pick an asset + `chmod +x`. **POSIX `sh`** (shellcheck-clean), detects `uname` OS/arch → target triple (**Linux → the fully-static `*-musl`** build, macOS → `*-apple-darwin`; `amd64`/`arm64` aliased; **Windows rejected** → Scoop/winget/releases page), resolves the version from the **`releases/latest`** API (or `RDBMS_VERSION` to pin), downloads the asset **and its `.sha256` and verifies it** (mismatch aborts), installs to `~/.local/bin` (`RDBMS_INSTALL_DIR` override) with a PATH hint. Testing seams: `RDBMS_OS`/`RDBMS_ARCH` + `--print-target`. macOS note: `curl` downloads aren't Gatekeeper-quarantined so the ad-hoc binary runs as-is (Developer-ID + notarization is the postponed signing task). **Verified end-to-end against the live public `v0.1.0`** (all platform mappings, pinned + latest, checksum incl. tamper-rejection, install + run). Rejected: website-domain hosting (extra moving part; Gitea raw is simplest); deferred: uploading the script as a release asset, and a **shellcheck CI gate** (shellcheck isn't in the flake — touches ADR-ci-002). **Amendment 1 (2026-06-17):** added a Windows **`scripts/install.ps1`** (`irm | iex`; maps host CPU → our `*-windows-gnu`/`-gnullvm` `.exe`, SHA-256-verifies, installs to `%LOCALAPPDATA%\Programs\…` + user PATH) — user chose both a one-liner *and* Scoop/winget; **written but untested from this env** (no PowerShell — validate on Windows). - [ADR-0056 — crates.io publish-readiness + `cargo binstall` metadata (D3)](0056-crates-io-and-cargo-binstall.md) — **Prepared 2026-06-17** (plan step 3a; tracked by plan + ADR). Makes the crate **ready to publish** to crates.io (user decision) and adds `cargo-binstall` metadata; the actual `cargo publish` is a **gated, irreversible maintainer step**. Manifest: drops `publish = false`; adds `homepage` (relplay.org), `keywords`, `categories`, and an `exclude` (`/website`,`/docs`,`/.gitea`,`/.codegraph`) trimming the crate from 585 files/8.3 MiB → **353/913 KiB compressed** (code-only). Authors **`README.md`** (engine-neutral, simple/advanced-mode wording; install via curl|sh/binstall/source/prebuilt) and **`LICENSE-MIT`** (© Lazy Evaluation Ltd — *confirm holder*); the canonical **`LICENSE-APACHE`** is deferred to the maintainer (don't ship retyped legal text) — the SPDX `license` field already satisfies crates.io. **binstall** (syntax verified vs cargo-binstall SUPPORT.md): `pkg-fmt = "bin"` (bare binaries), `pkg-url` spelled `v{ version }` (the placeholder omits the `v`), plus per-target **`overrides`** mapping the common host triples to the assets we ship — `*-linux-gnu` → the static `*-linux-musl` build, `*-pc-windows-msvc` → `*-gnu`/`-gnullvm` `.exe` (macOS matches directly; the docs promise no automatic fallback). **Ordering:** publish at a **new tagged version whose release exists**, after the release — **not `0.1.0`** (diverges from the already-released 0.1.0 binaries that predate `--version`). Verified: `cargo publish --dry-run` packages + verify-builds; `cargo metadata` confirms the binstall block + 4 overrides. **Unverified:** a real `cargo binstall` run (not a dep; nothing on crates.io yet) — validate at first publish. Rejected: cargo-dist (GitHub-centric). Maintainer follow-ups: confirm © holder, add canonical `LICENSE-APACHE`, real binstall validation. From 3c87dbb39169f527040e0751ec8c0df0af99e66c Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Thu, 18 Jun 2026 21:24:34 +0000 Subject: [PATCH 11/20] fix(ci): create the profile dir so macOS nix prune keeps the toolchain warm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prune step's profile path $HOME/.cache/rdbms-ci/toolchain had no parent dir, so `nix develop --profile` errored ("cannot read directory") and — swallowed by `|| true` — never created the profile/gc-root. With nothing rooting the toolchain, nix-collect-garbage deleted the whole closure every run (~3.8 GiB re-downloaded each dispatch; confirmed in the run-74 log). Add `mkdir -p` for the parent and drop the `|| true` on the profile realization so a future breakage fails loudly. The VM stays bounded as before, but the toolchain now persists across runs. release-macos.yaml is workflow_dispatch (runs from main's definition), so this takes effect on the next dispatch — the already-published v0.2.0 macOS binaries are unaffected. --- .gitea/workflows/release-macos.yaml | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/.gitea/workflows/release-macos.yaml b/.gitea/workflows/release-macos.yaml index 8f75829..628f29e 100644 --- a/.gitea/workflows/release-macos.yaml +++ b/.gitea/workflows/release-macos.yaml @@ -84,12 +84,21 @@ jobs: # 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. + # keep the 2 newest, reclaim what older ones referenced — so the + # toolchain stays *warm* across runs and only stale generations are GC'd. + # + # The profile's parent dir MUST exist first, or `nix develop --profile` + # errors ("cannot read directory …") and the profile/gc-root is never + # created — which made `nix-collect-garbage` delete the whole toolchain + # closure every run (re-downloaded ~3.8 GiB each time; the retention was + # silently broken by the swallowed `|| true`). No `|| true` on the + # profile realization now: a future breakage should fail loudly. if: always() run: | echo "--- disk before ---"; df -h / | tail -1 P="$HOME/.cache/rdbms-ci/toolchain" - nix develop --profile "$P" -c true || true + mkdir -p "$(dirname "$P")" + nix develop --profile "$P" -c true nix-env -p "$P" --delete-generations +2 || true nix-collect-garbage || true echo "--- disk after ---"; df -h / | tail -1 From d3af1c413a18a258683f4fcc940d5035b9a13d15 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Thu, 18 Jun 2026 22:03:47 +0000 Subject: [PATCH 12/20] ci: add a manual publish workflow (crates.io, idempotent + expandable) A workflow_dispatch publish.yaml (mirrors release-macos.yaml) with a `tag` input, run by hand once the automated release builds exist. Publishing stays manual and keeps the registry token off every tag push: it's irreversible (yank-only), the split release (tag Linux/Windows + dispatched macOS) makes a human the 'all assets up' gate, and crates.io has no Gitea-Actions trusted-publishing path. The crates.io job is idempotent (crates.io API pre-check + cargo publish as backstop) and independent (no inter-job needs), so future Scoop/Homebrew/winget jobs can be added alongside without interfering or breaking re-runs. Token via the CARGO_REGISTRY_TOKEN secret. ADR-0056 Amendment 1 + README index also record 0.2.0 published + binstall verified. --- docs/adr/0056-crates-io-and-cargo-binstall.md | 24 +++++++++++++++++++ docs/adr/README.md | 2 +- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/docs/adr/0056-crates-io-and-cargo-binstall.md b/docs/adr/0056-crates-io-and-cargo-binstall.md index a10d8ed..63dc06f 100644 --- a/docs/adr/0056-crates-io-and-cargo-binstall.md +++ b/docs/adr/0056-crates-io-and-cargo-binstall.md @@ -86,3 +86,27 @@ verified against cargo-binstall SUPPORT.md): - Remaining follow-up: run the real `cargo binstall` validation at the first publish + matching release (the license files, © holder, and CONTRIBUTING are now in place). + +## Amendment 1 — 2026-06-18: published live + a manual `publish` workflow + +**`rdbms-playground 0.2.0` is published to crates.io** (`cargo install` and +`cargo binstall rdbms-playground` both verified working by the user). The +"unverified binstall" caveat is resolved — the per-target overrides +resolve correctly against the `v0.2.0` release assets. + +**How publishing is wired:** a new **manual `workflow_dispatch` workflow** +(`.gitea/workflows/publish.yaml`), mirroring `release-macos.yaml`, takes a +`tag` input and runs `cargo publish` (token via the +`CARGO_REGISTRY_TOKEN` Gitea Actions secret — a crate-scoped, +publish-update token). **Not** automated on the tag, by decision: the +publish is irreversible (yank-only), keeping the registry token off every +tag push; the release is split (Linux/Windows on the tag, macOS +dispatched), so a human is the natural "all assets are up — go" gate; and +crates.io has no Gitea-Actions trusted-publishing path today, so a stored +token on the self-hosted runner would be the only automated option. +Each registry is its **own idempotent job** (no inter-job `needs`) — the +crates.io job skips cleanly if the version is already published (crates.io +API pre-check + `cargo publish` as the backstop) — so future +Scoop/Homebrew/winget jobs can be added alongside without breaking one +another or re-runs. The first such job's `tag`-vs-`Cargo.toml` guard +mirrors `release.yaml`. diff --git a/docs/adr/README.md b/docs/adr/README.md index 2fb4509..37160c7 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -68,4 +68,4 @@ This directory contains the project's ADRs, recorded per - [ADR-0053 — Contextual `hint` command and keybinding](0053-contextual-hint-command-and-keybinding.md) — **Accepted, implemented 2026-06-15** (Phases A–D; closes **A1** + requirements **H2**). Settles the `hint` slot ADR-0003 left "ADR pending"; closes the last open piece of **A1** and tracks requirements **H2**. **Two surfaces:** an **F1 keybinding** that renders a deep hint for the *live* partial input without submitting (the primary path — a submitted `hint` command can't see the buffer it would help with, since Enter empties it), and a submitted **`hint` command** that expands on the *most recent error*. **No topic argument** (contextual only — `help ` already owns explicit reference). Introduces a **tier-3 teaching layer**, deeper than the existing tier-1 (colour / error headline) and tier-2 (ambient one-liner; and the error `hint:`, which is shown **by default** since `Verbosity::Verbose` is the default — `messages short` is the opt-*out*); without it `hint` would just duplicate what's already on screen. Tier-3 content lives in the catalogue under `hint.cmd.` (per command form) and `hint.err.` (per error/diagnostic class), each a structured `what`/`example`/`concept` block rendered via a new `note_hint*` family with `OutputStyleClass::Hint`. **Keyed per-form via a new `hint_ids: &[&str]` field on `CommandNode` mirroring `usage_ids`** (revised in Phase B): a per-*node* key proved too coarse — `add`/`drop`/`show`/`create` are each one node spanning many forms, and a live-input hint for `add 1:n relationship` must be specific to relationships; `hint_key_for_input_in_mode` reuses `usage_key_for_input_in_mode`'s form-word disambiguation, and covers the advanced-SQL forms whose `usage_ids` are empty. Not keyed off `help_id` (it is `None` on the advanced-SQL nodes purely to dedup the `help` list; that parallel gap is issue **#36**). **Clause-concept hints** (`on delete` actions, constraint slots, `with pk`, cardinality) are a recorded **deferred extension** (`hint.concept.`, issue **#37**) — per-form is the right tier-3 granularity, with position-awareness owned by tier-2 + the live `Next:` line. Runtime `translate_error` classes resolve via stored `last_error_hint_key` (`hint` command / empty-F1). (The second route — pre-submit `diagnostic.*` read live from the walker on the F1 path — is **deferred**, issue **#38**: `Diagnostic` carries no class key.) Adds `AppCommand::Hint`, a `HINT` grammar node + REGISTRY entry, the `hint_ids` field, and `last_error_hint_key`; F1 is a read-only overlay (buffer + completion memo untouched). **Content is the bulk of the work** (the mechanism is ~a day): v1 scope = ~37 command forms + 9 runtime error classes (comprehensive for those, ~57 blocks), authored **exemplars-first** (voice approved in this ADR's `/runda` review, then mass-authored in batches), enforced by a **comprehensiveness coverage test**, with graceful fall-back to tier-2 if a key is ever missing. The **pre-submit-diagnostic route + ~33 `diagnostic.*` blocks were deferred** (issue **#38**) — `Diagnostic` carries no class key, so the route needs a broad change for marginal value (tier-2 already surfaces diagnostics; many duplicate runtime classes). Forks user-chosen: two-surface model; **F1** (vs `?` / a chord); no-arg; comprehensive-for-commands-and-errors scope; exemplars-first; diagnostics deferred. OOS: per-topic `hint ` (rejected — overlaps `help`); always-on tier-3 (rejected — keeps ambient terse); non-`en-US` locales + success-command teaching (deferred); clause-concept hints (issue #37); the diagnostic route (issue #38); the `help`-side advanced-SQL gap (issue #36) - [ADR-0054 — Release versioning policy + version surfaces (`--version` / `version`)](0054-release-versioning-and-version-surfaces.md) — **Accepted + implemented 2026-06-16** (plan: `docs/plans/20260616-public-availability.md`, step 1 on the road to public availability; no prior issue/`requirements.md` item — an untracked gap). Fixes the **tag↔crate-version decoupling**: `Cargo.toml` built `0.1.0` while `release.yaml` named assets from the git tag, so a binary could report a version different from the asset it shipped in. **Decision:** `Cargo.toml` `version` is the **single source of truth** (read via `env!("CARGO_PKG_VERSION")`, no tag-injection); two surfaces report it through one `cli::version_text()` → catalog `cli.version_line` — a **`--version` / `-V`** CLI flag (mirrors `--help`, prints+exits in `main.rs`) and an in-app **`version`** command (REGISTRY node `app::VERSION`, `AppCommand::Version`, emits via `note_system`); and a **release-CI version guard** (`release.yaml` `test` job reads the `[package]` version from `Cargo.toml` and **fails the release** unless the `v*` tag equals `v`; the guard's parse was later switched from `cargo metadata | node` to a `grep` on Cargo.toml after the former broke on the flake devShell's stdout banner). Release ritual: bump `Cargo.toml` → commit → tag → push. New keys `cli.version_line` + `help.app.version` + `parse.usage.version` + `hint.cmd.version.{what,example}` (the new REGISTRY command pulls in the comprehensiveness coverage gate). Rejected: tag-as-source (makes Cargo.toml lie). Deferred: git-hash/build-date enrichment (behind the same `version_text()` seam); UI placement beyond the command. Tested test-first: CLI parse (`--version`/`-V`/default-off), `version_text()` carries `CARGO_PKG_VERSION`, the in-app command parses + emits. Also corrected a stale `release.yaml` header comment ("macOS is deferred" → built by the dispatched `release-macos.yaml`). - [ADR-0055 — `curl | sh` install script (`scripts/install.sh`)](0055-curl-sh-install-script.md) — **Accepted + implemented 2026-06-17** (plan: `docs/plans/20260616-public-availability.md`, step 2; tracked by plan + ADR, no Gitea issue — user decision). A one-line installer (`curl -fsSL /scripts/install.sh | sh`) so beginners don't hand-pick an asset + `chmod +x`. **POSIX `sh`** (shellcheck-clean), detects `uname` OS/arch → target triple (**Linux → the fully-static `*-musl`** build, macOS → `*-apple-darwin`; `amd64`/`arm64` aliased; **Windows rejected** → Scoop/winget/releases page), resolves the version from the **`releases/latest`** API (or `RDBMS_VERSION` to pin), downloads the asset **and its `.sha256` and verifies it** (mismatch aborts), installs to `~/.local/bin` (`RDBMS_INSTALL_DIR` override) with a PATH hint. Testing seams: `RDBMS_OS`/`RDBMS_ARCH` + `--print-target`. macOS note: `curl` downloads aren't Gatekeeper-quarantined so the ad-hoc binary runs as-is (Developer-ID + notarization is the postponed signing task). **Verified end-to-end against the live public `v0.1.0`** (all platform mappings, pinned + latest, checksum incl. tamper-rejection, install + run). Rejected: website-domain hosting (extra moving part; Gitea raw is simplest); deferred: uploading the script as a release asset, and a **shellcheck CI gate** (shellcheck isn't in the flake — touches ADR-ci-002). **Amendment 1 (2026-06-17):** added a Windows **`scripts/install.ps1`** (`irm | iex`; maps host CPU → our `*-windows-gnu`/`-gnullvm` `.exe`, SHA-256-verifies, installs to `%LOCALAPPDATA%\Programs\…` + user PATH) — user chose both a one-liner *and* Scoop/winget; **written but untested from this env** (no PowerShell — validate on Windows). -- [ADR-0056 — crates.io publish-readiness + `cargo binstall` metadata (D3)](0056-crates-io-and-cargo-binstall.md) — **Prepared 2026-06-17** (plan step 3a; tracked by plan + ADR). Makes the crate **ready to publish** to crates.io (user decision) and adds `cargo-binstall` metadata; the actual `cargo publish` is a **gated, irreversible maintainer step**. Manifest: drops `publish = false`; adds `homepage` (relplay.org), `keywords`, `categories`, and an `exclude` (`/website`,`/docs`,`/.gitea`,`/.codegraph`) trimming the crate from 585 files/8.3 MiB → **353/913 KiB compressed** (code-only). Authors **`README.md`** (engine-neutral, simple/advanced-mode wording; install via curl|sh/binstall/source/prebuilt) and **`LICENSE-MIT`** (© Lazy Evaluation Ltd — *confirm holder*); the canonical **`LICENSE-APACHE`** is deferred to the maintainer (don't ship retyped legal text) — the SPDX `license` field already satisfies crates.io. **binstall** (syntax verified vs cargo-binstall SUPPORT.md): `pkg-fmt = "bin"` (bare binaries), `pkg-url` spelled `v{ version }` (the placeholder omits the `v`), plus per-target **`overrides`** mapping the common host triples to the assets we ship — `*-linux-gnu` → the static `*-linux-musl` build, `*-pc-windows-msvc` → `*-gnu`/`-gnullvm` `.exe` (macOS matches directly; the docs promise no automatic fallback). **Ordering:** publish at a **new tagged version whose release exists**, after the release — **not `0.1.0`** (diverges from the already-released 0.1.0 binaries that predate `--version`). Verified: `cargo publish --dry-run` packages + verify-builds; `cargo metadata` confirms the binstall block + 4 overrides. **Unverified:** a real `cargo binstall` run (not a dep; nothing on crates.io yet) — validate at first publish. Rejected: cargo-dist (GitHub-centric). Maintainer follow-ups: confirm © holder, add canonical `LICENSE-APACHE`, real binstall validation. +- [ADR-0056 — crates.io publish-readiness + `cargo binstall` metadata (D3)](0056-crates-io-and-cargo-binstall.md) — **Prepared 2026-06-17** (plan step 3a; tracked by plan + ADR). Makes the crate **ready to publish** to crates.io (user decision) and adds `cargo-binstall` metadata; the actual `cargo publish` is a **gated, irreversible maintainer step**. Manifest: drops `publish = false`; adds `homepage` (relplay.org), `keywords`, `categories`, and an `exclude` (`/website`,`/docs`,`/.gitea`,`/.codegraph`) trimming the crate from 585 files/8.3 MiB → **353/913 KiB compressed** (code-only). Authors **`README.md`** (engine-neutral, simple/advanced-mode wording; install via curl|sh/binstall/source/prebuilt) and **`LICENSE-MIT`** (© Lazy Evaluation Ltd — *confirm holder*); the canonical **`LICENSE-APACHE`** is deferred to the maintainer (don't ship retyped legal text) — the SPDX `license` field already satisfies crates.io. **binstall** (syntax verified vs cargo-binstall SUPPORT.md): `pkg-fmt = "bin"` (bare binaries), `pkg-url` spelled `v{ version }` (the placeholder omits the `v`), plus per-target **`overrides`** mapping the common host triples to the assets we ship — `*-linux-gnu` → the static `*-linux-musl` build, `*-pc-windows-msvc` → `*-gnu`/`-gnullvm` `.exe` (macOS matches directly; the docs promise no automatic fallback). **Ordering:** publish at a **new tagged version whose release exists**, after the release — **not `0.1.0`** (diverges from the already-released 0.1.0 binaries that predate `--version`). Verified: `cargo publish --dry-run` packages + verify-builds; `cargo metadata` confirms the binstall block + 4 overrides. **Unverified:** a real `cargo binstall` run (not a dep; nothing on crates.io yet) — validate at first publish. Rejected: cargo-dist (GitHub-centric). Maintainer follow-ups: confirm © holder, add canonical `LICENSE-APACHE`, real binstall validation. **Amendment 1 (2026-06-18):** `0.2.0` **published live** (crates.io; `cargo install` + `cargo binstall` verified — the unverified-overrides caveat is resolved), via a new **manual `workflow_dispatch`** workflow `.gitea/workflows/publish.yaml` (mirrors `release-macos.yaml`; `tag` input; `cargo publish` with a crate-scoped `CARGO_REGISTRY_TOKEN` secret). Publish stays **manual** by decision — irreversible (keeps the token off every tag push), the split release (tag Linux/Windows + dispatched macOS) makes a human the "all assets up" gate, and crates.io has no Gitea-Actions trusted-publishing path. Each registry is its **own idempotent job** (crates.io job no-ops if the version exists) so Scoop/Homebrew/winget can be added as sibling jobs without interfering. From 8ebe213b5dce7e62fa3d84021792d8bf9ccc9e48 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Thu, 18 Jun 2026 22:10:21 +0000 Subject: [PATCH 13/20] ci: add the publish.yaml workflow file (completes d3af1c4) d3af1c4 described the manual publish workflow and updated ADR-0056, but `git commit -am` doesn't stage new untracked files, so publish.yaml itself was left out. Add it here. --- .gitea/workflows/publish.yaml | 79 +++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 .gitea/workflows/publish.yaml diff --git a/.gitea/workflows/publish.yaml b/.gitea/workflows/publish.yaml new file mode 100644 index 0000000..61bed46 --- /dev/null +++ b/.gitea/workflows/publish.yaml @@ -0,0 +1,79 @@ +# Manual publication workflow (workflow_dispatch) — the outward, irreversible +# release steps a human triggers AFTER the automated `release.yaml` build has +# produced downloadable assets (and they've been eyeballed as good). +# +# Why manual + separate from release.yaml: +# * Publishing to a public registry is irreversible (crates.io versions can +# only be *yanked*, never deleted) — a human pulls this lever, and the +# registry token never sits on every tag push. +# * Our release is split (Linux/Windows on the tag, macOS dispatched), so a +# human is the natural "all assets are up — go" gate. crates.io publish +# reads SOURCE so it doesn't strictly need the release, but binstall's +# metadata points at the release assets — hence run this once builds exist. +# +# Structure: each registry is its OWN job with NO inter-job `needs`, so jobs run +# independently and one failing (or a newly-added one) never breaks another. +# Every job is IDEMPOTENT — re-dispatching when a target is already published is +# a clean no-op. Add Scoop / Homebrew / winget as sibling jobs here later. +name: publish +on: + workflow_dispatch: + inputs: + tag: + description: 'Release tag to publish (e.g. v0.2.0)' + required: true + +jobs: + crates-io: + runs-on: ci-public + container: + image: git.lazyeval.net/oli/rdbms-playground-ci:latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.tag }} + + - name: publish to crates.io (idempotent) + shell: bash + env: + TAG: ${{ inputs.tag }} + # A crate-scoped, publish-update crates.io token, stored as a Gitea + # Actions secret. `cargo publish` reads CARGO_REGISTRY_TOKEN from env. + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + run: | + set -euo pipefail + + # Source of truth = the [package] version at the checked-out tag + # (toolchain-free read; same approach as release.yaml's guard, which + # avoids the flake devShell's stdout banner corrupting a parse). + VER=$(grep -m1 '^version = ' Cargo.toml | sed -E 's/^version = "(.*)"/\1/') + [ -n "$VER" ] || { echo "ERROR: could not read version from Cargo.toml" >&2; exit 1; } + if [ "$TAG" != "v$VER" ]; then + echo "ERROR: dispatch tag '$TAG' != 'v$VER' (Cargo.toml at that tag)" >&2 + exit 1 + fi + + # Idempotency: if this version is already on crates.io, no-op. + # (crates.io requires a descriptive User-Agent per its data policy; + # without one the API returns 403.) Only an explicit 200 means + # "already there" — anything else proceeds, and `cargo publish` is the + # final backstop (it refuses to overwrite an existing version). + UA="rdbms-playground-release-ci (oliver@sturmnet.org)" + code=$(curl -sS -o /dev/null -w '%{http_code}' -A "$UA" \ + "https://crates.io/api/v1/crates/rdbms-playground/$VER" || echo 000) + if [ "$code" = "200" ]; then + echo "rdbms-playground $VER is already on crates.io — nothing to do." + exit 0 + fi + echo "crates.io returned HTTP $code for $VER (not 200) — proceeding to publish." + + echo "publishing rdbms-playground $VER to crates.io ..." + nix develop -c cargo publish --locked + echo "published rdbms-playground $VER to crates.io." + + # Future manual publication targets go here as independent, idempotent jobs, + # e.g.: + # scoop-bucket: { ... update the lazyeval Scoop bucket manifest ... } + # homebrew-tap: { ... update the lazyeval Homebrew formula ... } + # winget: { ... komac submit, or a manual PR helper ... } + # No `needs:` between them — each checks the target and skips if already done. From cabc8131a9faf8e66ddc9cfbc43e6b20611b65f9 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Thu, 18 Jun 2026 22:10:41 +0000 Subject: [PATCH 14/20] =?UTF-8?q?docs:=20handoff=2074=20=E2=80=94=20road?= =?UTF-8?q?=20to=20public=20availability;=20v0.2.0=20live=20on=20crates.io?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/handoff/20260618-handoff-74.md | 137 ++++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 docs/handoff/20260618-handoff-74.md diff --git a/docs/handoff/20260618-handoff-74.md b/docs/handoff/20260618-handoff-74.md new file mode 100644 index 0000000..00da05b --- /dev/null +++ b/docs/handoff/20260618-handoff-74.md @@ -0,0 +1,137 @@ +# Session handoff — 2026-06-18 (74) + +Large session. Continues from handoff-73 (Ctrl-G demo alias). This one ran +the **road to public availability** end to end: a post-merge doc +reconciliation, then versioning, installers, crates.io/binstall, the +`cargo fmt` gate, and an actual **`v0.2.0` release that is now live on +crates.io**. Three new main-sequence ADRs (0054/0055/0056), one CI-ADR +amendment, issue **#35 closed**. + +## §1. State + +**Branch `main`.** **2509 pass / 0 fail / 1 ignored** (the long-standing +`friendly` doctest); **clippy clean**; **`cargo fmt --check` clean** (the +tree is now stock-rustfmt formatted, and CI gates it). `rdbms-playground +--version` → `rdbms-playground 0.2.0`. + +**Released:** **`v0.2.0`** — Gitea release with all six D1 targets +(Linux/Windows via `release.yaml` on the tag; macOS via the dispatched +`release-macos.yaml`, **ad-hoc-signed**). **Published to crates.io** +(`cargo install rdbms-playground` and `cargo binstall rdbms-playground` +both user-verified). + +**Push state:** earlier commits were pushed (they triggered CI/releases). +The **most recent commits are unpushed** and matter: +- `3c87dbb` macOS nix-prune fix (takes effect next macOS dispatch). +- `d3af1c4` + `8ebe213` the manual `publish.yaml` workflow — ADR-0056 in + the first, the **workflow file itself in the second** (`git commit -am` + had skipped the new untracked file). +- this handoff. +Push `main` to land them. (Push is the user's step.) + +## §2. What shipped (commits `628b250`→`d3af1c4`) + +- **`628b250` doc reconciliation** after the CI + website branch merges: + rewrote CLAUDE.md's stale repo-layout tree; added a Website subproject + note; fixed the CI note; **gitignored `.wrangler/` + `.vscode/`** and + removed a tracked `website/.vscode/`; updated requirements (D1 macOS + runtime-verified, DOC1 canonical-docs-on-website) + ADR-ci-003. +- **`c30a611` ADR-0054 — version surfaces.** `--version`/`-V` + an in-app + **`version`** command, both reading `CARGO_PKG_VERSION` via one + `cli::version_text()`. A **release-CI guard** fails the release unless + the `v*` tag equals `v`. +- **`ef99e6c` ADR-0055 — `scripts/install.sh`** (curl|sh, POSIX, + shellcheck-clean, checksum-verified, `~/.local/bin`). Verified + end-to-end against the live release. **`install.ps1`** (Windows + `irm|iex`) added later (`e9606b5`) — **written but untested here** (no + PowerShell on this box; validate on Windows). +- **`e9606b5` ADR-0056 — crates.io + binstall prep.** Publish-ready + Cargo.toml (dropped `publish=false`; homepage/keywords/categories/ + `exclude`); `README.md`; `LICENSE-MIT`/`LICENSE-APACHE` (dual, © Lazy + Evaluation Ltd) + `CONTRIBUTING.md` (inbound=outbound); the + `[package.metadata.binstall]` block with per-target overrides + (linux-gnu→musl, windows-msvc→gnu/gnullvm; macOS direct). +- **`41b7e9a` + `ec3c7c3` — #35 fmt gate.** One mechanical `cargo fmt` + (stock defaults, 102 files, behaviour-preserving) recorded in + `.git-blame-ignore-revs`; `ci.yaml` now gates `fmt --check` (ADR-ci-002 + Amendment 1). **Closes #35.** +- **`88830ed`+`bd5be5e` — v0.2.0 bump + the guard bug.** The first + `release.yaml` run **failed** at the version guard: it piped `nix + develop -c cargo metadata` to node, but the **flake devShell prints a + banner to stdout**, corrupting the JSON. Fixed to a toolchain-free + `grep -m1 '^version = ' Cargo.toml`. The `v0.2.0` tag was re-pointed + (Option A) to the fix commit; re-run went green. +- **`3c87dbb` — macOS nix-prune fix.** The prune step's profile dir + (`~/.cache/rdbms-ci`) didn't exist, so `nix develop --profile` errored + (swallowed by `|| true`) → the gc-root was never created → the whole + toolchain (~3.8 GiB) was deleted **and re-downloaded every run**. Added + `mkdir -p` + dropped the `|| true`. Diagnosed from the run-74 log via + `tea actions runs logs 74`. +- **`d3af1c4` — manual `publish.yaml`.** `workflow_dispatch` + `tag` + input (mirrors `release-macos.yaml`). Idempotent `crates-io` job + (crates.io API pre-check + `cargo publish` backstop), independent jobs + so Scoop/Homebrew/winget slot in later. ADR-0056 Amendment 1. + +## §3. Live vs manual vs parked + +- **Automated on a `v*` tag:** `release.yaml` builds + publishes the four + Linux/Windows targets (+ fmt/clippy/test gate). +- **Manual `workflow_dispatch`:** `release-macos.yaml` (mac binaries — + intermittent runner) and `publish.yaml` (crates.io now; more registries + later). Run them once the tag's build is up. +- **Parked (user decisions):** + - **macOS Developer-ID signing.** The pipeline **ad-hoc-signs** + (`codesign --sign -`). The user's `Apple Development` cert is the + **wrong type** — distribution needs **`Developer ID Application`** + + **notarization** (App Store Connect API key recommended). Fine for + `curl|sh` (no quarantine); matters for browser downloads. Details in + `docs/plans/20260616-public-availability.md`. + - **Remaining D3:** Scoop (`lazyeval` bucket), Homebrew (`lazyeval` + tap), winget (komac on Linux CI, or manual PR) — each a sibling job + in `publish.yaml` + a manifest repo. + +## §4. Immediate next steps + +1. **Push `main`** (lands `3c87dbb` + `d3af1c4`). +2. **Add the `CARGO_REGISTRY_TOKEN` secret** (crate-scoped, + `publish-update`) so `publish.yaml` works: `tea actions secrets create + CARGO_REGISTRY_TOKEN` (paste at prompt) or the Gitea UI. +3. **Smoke-test `publish.yaml`:** dispatch it for `v0.2.0` — it should + **idempotently skip** ("already on crates.io"), exercising the path + risk-free. +4. The release ritual going forward (ADR-0054): bump `Cargo.toml` → + commit → tag `v` → push tag (Linux/Windows release builds) → + dispatch `release-macos` → dispatch `publish`. + +## §5. Gotchas learned (don't relearn the hard way) + +- **The flake devShell prints a banner to stdout** — never pipe `nix + develop -c ` into a parser. Read Cargo.toml directly, etc. +- **Workflow-file source differs by trigger:** a **tag**-triggered run + (`release.yaml`) uses the workflow **at the tagged commit**; a + **`workflow_dispatch`** run (`release-macos`/`publish`) uses the + **default branch** (`main`). So fixing a dispatched workflow only needs + a `main` push; fixing a tag-triggered one needs the tag re-pointed. +- **Version vs tag:** `Cargo.toml` is bare `0.2.0`; the git tag is + `v0.2.0`; the guard checks `tag == "v" + version`; binstall `pkg-url` + spells `v{ version }`. +- **CI logs are reachable** via `tea actions runs logs ` (and `tea + actions runs list --output tsv`). Use it instead of guessing from a + step name. +- **crates.io API needs a descriptive User-Agent** (403 without one). + +## §6. How to take over + +1. Read handoffs 72 → 73 → 74, `CLAUDE.md`, `docs/requirements.md`, and + **`docs/plans/20260616-public-availability.md`** (the GA roadmap with + all decisions + parked items). +2. Confirm green: `cargo test` (**2509 / 1 ignored**), `cargo clippy + --all-targets`, `cargo fmt --check`. +3. ADRs for this arc: **0054** (versioning), **0055** (installer), + **0056** (crates.io/binstall + the publish workflow); CI side + **ADR-ci-002 Amendment 1** (fmt gate), **ADR-ci-003** (release matrix + + macOS). +4. Workflow unchanged: phased, test-first, `/runda` + DA before commits, + ADR amendment + README index-upkeep for decided-area changes, confirm + commit messages, never push. +5. Consider a `cargo sweep` at this milestone (`target/` grows). From 42b40bc0998e18192cc3b0d1e620ef1c8b5a9c08 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Fri, 19 Jun 2026 14:22:28 +0000 Subject: [PATCH 15/20] fix(install): make install.ps1 work on Windows PowerShell 5.1 The in-box Windows shell (5.1, .NET Framework) is the baseline we must support; PowerShell 7 is an opt-in install most users don't have. - arch detection: read PROCESSOR_ARCHITECTURE/ARCHITEW6432 from the environment instead of RuntimeInformation::OSArchitecture, which resolves from a .NET Framework facade lacking that property under 5.1 and throws under StrictMode (the reported failure). - force TLS 1.2 before any web request (5.1 may default to TLS 1.0/1.1). - pass -UseBasicParsing to Invoke-WebRequest (5.1 otherwise uses the IE engine and can fail when it is absent). All three are no-ops on PowerShell 7. Relates to ADR-0055. --- scripts/install.ps1 | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/scripts/install.ps1 b/scripts/install.ps1 index b2ffe2c..6a9228d 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -37,15 +37,30 @@ param( Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' +# Windows PowerShell 5.1 (the in-box shell) can negotiate only TLS 1.0/1.1 by +# default, which modern hosts reject. Opt into TLS 1.2 without disturbing any +# protocols already enabled. (No-op on PowerShell 7.) +[Net.ServicePointManager]::SecurityProtocol = + [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 + $Repo = 'https://git.lazyeval.net/oli/rdbms-playground' $Api = 'https://git.lazyeval.net/api/v1/repos/oli/rdbms-playground' $Bin = 'rdbms-playground' # Map the host CPU to the target triple we publish for Windows. -$osArch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture +# Read the architecture from the environment rather than +# RuntimeInformation::OSArchitecture: under Windows PowerShell 5.1 that type +# resolves from a .NET Framework facade that lacks OSArchitecture, which (with +# StrictMode) throws "property cannot be found". PROCESSOR_ARCHITECTURE is set +# on every PowerShell version; PROCESSOR_ARCHITEW6432 reports the true OS +# architecture when a 32-bit shell runs under WOW64. +$osArch = [Environment]::GetEnvironmentVariable('PROCESSOR_ARCHITEW6432') +if (-not $osArch) { + $osArch = [Environment]::GetEnvironmentVariable('PROCESSOR_ARCHITECTURE') +} switch ($osArch) { - 'X64' { $target = 'x86_64-pc-windows-gnu' } - 'Arm64' { $target = 'aarch64-pc-windows-gnullvm' } + 'AMD64' { $target = 'x86_64-pc-windows-gnu' } + 'ARM64' { $target = 'aarch64-pc-windows-gnullvm' } default { throw "install: unsupported CPU architecture: $osArch" } } @@ -65,8 +80,11 @@ try { $shaFile = "$exe.sha256" Write-Host "downloading $asset ..." - Invoke-WebRequest -Uri $url -OutFile $exe - Invoke-WebRequest -Uri "$url.sha256" -OutFile $shaFile + # -UseBasicParsing: Windows PowerShell 5.1's Invoke-WebRequest otherwise + # tries to use the Internet Explorer engine and can fail when it is absent. + # (No-op on PowerShell 7.) + Invoke-WebRequest -UseBasicParsing -Uri $url -OutFile $exe + Invoke-WebRequest -UseBasicParsing -Uri "$url.sha256" -OutFile $shaFile # The sidecar is " "; compare just the hash. $expected = ((Get-Content -Raw $shaFile) -split '\s+')[0].ToLower() From c0531aa048f300d74891c84abde7b208a98f6b4f Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Fri, 19 Jun 2026 14:57:25 +0000 Subject: [PATCH 16/20] fix(install): use install.ps1 immediately + honest PATH guidance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The persisted User PATH only reaches newly-started processes, so the old "restart your shell" advice was wrong — a sign-out/in was actually needed (observed on Windows 11). Update the current session's $env:Path as well so the command works right away in the same window, and reword the notice. Also refresh the header: verified on ARM64 Windows 11 under Windows PowerShell 5.1 and PowerShell 7.6 against the live v0.2.0 release. --- scripts/install.ps1 | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 6a9228d..dcfc521 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -24,9 +24,11 @@ %LOCALAPPDATA%\Programs\rdbms-playground. .NOTES - Written but NOT tested on Windows from this environment (no PowerShell - here) — validate on a real Windows host. The verified sibling is - install.sh (Linux/macOS). + Verified end-to-end on ARM64 Windows 11 under both Windows PowerShell 5.1 + and PowerShell 7.6, against the live v0.2.0 release. The x86_64 branch is + symmetric (env-based arch detection + a confirmed matching release asset) + but has not been run directly. The sibling installer is install.sh + (Linux/macOS). #> [CmdletBinding()] param( @@ -98,14 +100,22 @@ try { Move-Item -Path $exe -Destination $dest -Force Write-Host "installed $Bin $Version -> $dest" - # Add the install dir to the user PATH (persistent) if it's not there. + # Persist the install dir on the user PATH (for future shells) if missing. $userPath = [Environment]::GetEnvironmentVariable('Path', 'User') if (-not $userPath) { $userPath = '' } if (($userPath -split ';') -notcontains $InstallDir) { $newPath = if ($userPath) { "$userPath;$InstallDir" } else { $InstallDir } [Environment]::SetEnvironmentVariable('Path', $newPath, 'User') - Write-Host "added $InstallDir to your user PATH — restart your shell to pick it up" } + # Also update THIS session's PATH so the command works immediately. The + # persisted change only reaches newly-started processes; an already-running + # shell (and, depending on how the terminal inherited its environment, even + # a freshly-opened one) won't see it until the next sign-out/in. + if (($env:Path -split ';') -notcontains $InstallDir) { + $env:Path = "$env:Path;$InstallDir" + } + Write-Host "added $InstallDir to your PATH — '$Bin' works in this window now." + Write-Host "for shells already open elsewhere, sign out and back in (or open a fresh one)." } finally { Remove-Item -Path $tmp -Recurse -Force -ErrorAction SilentlyContinue From 6d54c1e96c199199204c2434aa47599b61766974 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Fri, 19 Jun 2026 21:30:18 +0000 Subject: [PATCH 17/20] =?UTF-8?q?ci(publish):=20wire=20Scoop=20bucket=20+?= =?UTF-8?q?=20Homebrew=20tap=20jobs=20(D3=20=C2=A73b/=C2=A73c)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add sibling publish.yaml jobs (scoop-bucket, homebrew-tap) that render a manifest from the release .sha256 sidecars and idempotently push it to the org-level lazyeval/scoop-bucket and lazyeval/homebrew-tap repos, using the scoped lazyeval-ci bot token (LAZYEVAL_PKG_TOKEN). Render logic lives in dependency-free bash (the CI image has no jq/ruby): scripts/render-scoop-manifest.sh and scripts/render-homebrew-formula.sh. scripts/test-package-renders.sh exercises both: it validates the Scoop JSON with node and asserts fields on both manifests, and additionally runs `ruby -c` on the formula where ruby is present (dev box), skipping it gracefully otherwise. A new ci.yaml `manifests` job runs that test on every push so a render regression surfaces immediately, not at the next manual publish dispatch. The CI image has no ruby, so in CI the gate covers the Scoop JSON (node) and field assertions for both manifests; the formula's Ruby syntax is checked dev-side only (the static heredoc's variable parts cannot introduce syntax errors). - Scoop: x64 (gnu) + arm64 (gnullvm); #/-rename fragment so the bin shim is version-stable; checkver, no autoupdate (the pipeline is the updater). - Homebrew: on_macos/on_linux x arch bare-binary formula; no Windows. Docs: ADR-0056 Amendment 2 (+ README index, requirements D3). Unverified pending real use: scoop/brew install, the HEAD:main branch assumption, macOS Gatekeeper-via-brew on the ad-hoc-signed binary. --- .gitea/workflows/ci.yaml | 23 +++ .gitea/workflows/publish.yaml | 136 +++++++++++++++++- docs/adr/0056-crates-io-and-cargo-binstall.md | 66 +++++++++ docs/adr/README.md | 2 +- docs/requirements.md | 15 +- scripts/render-homebrew-formula.sh | 86 +++++++++++ scripts/render-scoop-manifest.sh | 66 +++++++++ scripts/test-package-renders.sh | 92 ++++++++++++ 8 files changed, 474 insertions(+), 12 deletions(-) create mode 100755 scripts/render-homebrew-formula.sh create mode 100755 scripts/render-scoop-manifest.sh create mode 100755 scripts/test-package-renders.sh diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 232f564..45cb0a4 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -6,6 +6,11 @@ # was enabled once the tree was reformatted on main (ADR-ci-002 Amendment 1 / # issue #35). The release job (static binary for D2) and the platform matrix # layer on later, step by step. +# +# A separate, lightweight `manifests` job logic-tests the package-manifest +# render scripts (Scoop/Homebrew) used by publish.yaml — bash + node only, no +# toolchain — so a render regression surfaces on the breaking push rather than +# weeks later at the next manual publish dispatch (ADR-0056 Amendment 2). name: ci on: push: @@ -46,3 +51,21 @@ jobs: run: nix develop -c cargo clippy --all-targets -- -D warnings - name: test run: nix develop -c cargo test --no-fail-fast + + # Logic test for the package-manifest render scripts. Renders with DUMMY + # inputs and validates the output — it never publishes or touches the lazyeval + # repos (that is publish.yaml's manual job). Runs on the same image but skips + # nix: it needs only bash + node, both in the base image. + # + # NOTE: the CI image has no ruby, so the script's `ruby -c` formula syntax + # check is skipped here (it degrades gracefully); the Scoop JSON is still + # validated with node and both manifests' fields are asserted. Full formula + # syntax is checked dev-side (ruby present) on every pre-commit local run. + manifests: + runs-on: ci-public + container: + image: git.lazyeval.net/oli/rdbms-playground-ci:latest + steps: + - uses: actions/checkout@v4 + - name: render-script tests (Scoop + Homebrew) + run: bash scripts/test-package-renders.sh diff --git a/.gitea/workflows/publish.yaml b/.gitea/workflows/publish.yaml index 61bed46..3383b71 100644 --- a/.gitea/workflows/publish.yaml +++ b/.gitea/workflows/publish.yaml @@ -71,9 +71,133 @@ jobs: nix develop -c cargo publish --locked echo "published rdbms-playground $VER to crates.io." - # Future manual publication targets go here as independent, idempotent jobs, - # e.g.: - # scoop-bucket: { ... update the lazyeval Scoop bucket manifest ... } - # homebrew-tap: { ... update the lazyeval Homebrew formula ... } - # winget: { ... komac submit, or a manual PR helper ... } - # No `needs:` between them — each checks the target and skips if already done. + # Update the lazyeval Scoop bucket (Windows). Renders the manifest from the + # release's .sha256 sidecars and commits it to lazyeval/scoop-bucket. Pushes + # with the lazyeval-ci bot token (LAZYEVAL_PKG_TOKEN), which is scoped — via + # the bot's org-team membership — to the lazyeval package repos only, so a + # leak cannot touch oli/rdbms-playground. + scoop-bucket: + runs-on: ci-public + container: + image: git.lazyeval.net/oli/rdbms-playground-ci:latest + steps: + - uses: actions/checkout@v4 # default ref (main) — current render script + + - name: update the lazyeval Scoop bucket (idempotent) + shell: bash + env: + TAG: ${{ inputs.tag }} + # Passed via env, never inlined into the script, so the value stays + # masked in logs; it only materialises in the clone URL at runtime. + PKG_TOKEN: ${{ secrets.LAZYEVAL_PKG_TOKEN }} + run: | + set -euo pipefail + VER="${TAG#v}" + echo "scoop: targeting rdbms-playground $VER ($TAG)" + + base="https://git.lazyeval.net/oli/rdbms-playground/releases/download/$TAG" + fetch_hash() { + local asset="$1" line + echo "scoop: fetching $asset.sha256" >&2 + line=$(curl -fsSL "$base/$asset.sha256") \ + || { echo "ERROR: cannot fetch $asset.sha256 — is $TAG released with assets?" >&2; exit 1; } + # First whitespace-delimited field is the hash. `read` is a bash + # builtin (no awk, which the slim CI image may lack). + local hash _ + read -r hash _ <<<"$line" + printf '%s' "$hash" + } + h_x64=$(fetch_hash "rdbms-playground-$TAG-x86_64-pc-windows-gnu.exe") + h_arm=$(fetch_hash "rdbms-playground-$TAG-aarch64-pc-windows-gnullvm.exe") + + echo "scoop: rendering manifest" + bash scripts/render-scoop-manifest.sh "$VER" "$h_x64" "$h_arm" > /tmp/rdbms-playground.json + node -e 'JSON.parse(require("fs").readFileSync("/tmp/rdbms-playground.json","utf8"))' \ + || { echo "ERROR: rendered Scoop manifest is not valid JSON" >&2; exit 1; } + + work=$(mktemp -d) + echo "scoop: cloning lazyeval/scoop-bucket" + git clone --depth 1 "https://lazyeval-ci:${PKG_TOKEN}@git.lazyeval.net/lazyeval/scoop-bucket.git" "$work" + cp /tmp/rdbms-playground.json "$work/rdbms-playground.json" + + cd "$work" + git config user.name "lazyeval-ci" + git config user.email "ci@lazyeval.net" + git add rdbms-playground.json + if git diff --cached --quiet; then + echo "scoop: manifest already at $VER — nothing to commit." + exit 0 + fi + git commit -m "rdbms-playground $VER" + # Push to main explicitly: a freshly-created (empty) repo clone may put + # the first commit on a differently-named local branch. Assumes the + # bucket/tap default branch is `main` (Gitea's default for new repos). + git push origin HEAD:main + echo "scoop: bucket updated to rdbms-playground $VER." + + # Update the lazyeval Homebrew tap (macOS + Linux). Same shape as scoop-bucket; + # writes Formula/rdbms-playground.rb into lazyeval/homebrew-tap. + homebrew-tap: + runs-on: ci-public + container: + image: git.lazyeval.net/oli/rdbms-playground-ci:latest + steps: + - uses: actions/checkout@v4 + + - name: update the lazyeval Homebrew tap (idempotent) + shell: bash + env: + TAG: ${{ inputs.tag }} + PKG_TOKEN: ${{ secrets.LAZYEVAL_PKG_TOKEN }} + run: | + set -euo pipefail + VER="${TAG#v}" + echo "homebrew: targeting rdbms-playground $VER ($TAG)" + + base="https://git.lazyeval.net/oli/rdbms-playground/releases/download/$TAG" + fetch_hash() { + local asset="$1" line + echo "homebrew: fetching $asset.sha256" >&2 + line=$(curl -fsSL "$base/$asset.sha256") \ + || { echo "ERROR: cannot fetch $asset.sha256 — is $TAG released with assets?" >&2; exit 1; } + # First whitespace-delimited field is the hash. `read` is a bash + # builtin (no awk, which the slim CI image may lack). + local hash _ + read -r hash _ <<<"$line" + printf '%s' "$hash" + } + mac_arm=$(fetch_hash "rdbms-playground-$TAG-aarch64-apple-darwin") + mac_x64=$(fetch_hash "rdbms-playground-$TAG-x86_64-apple-darwin") + lin_arm=$(fetch_hash "rdbms-playground-$TAG-aarch64-unknown-linux-musl") + lin_x64=$(fetch_hash "rdbms-playground-$TAG-x86_64-unknown-linux-musl") + + echo "homebrew: rendering formula" + bash scripts/render-homebrew-formula.sh "$VER" "$mac_arm" "$mac_x64" "$lin_arm" "$lin_x64" \ + > /tmp/rdbms-playground.rb + grep -q '^class RdbmsPlayground < Formula$' /tmp/rdbms-playground.rb \ + || { echo "ERROR: rendered formula looks malformed" >&2; exit 1; } + + work=$(mktemp -d) + echo "homebrew: cloning lazyeval/homebrew-tap" + git clone --depth 1 "https://lazyeval-ci:${PKG_TOKEN}@git.lazyeval.net/lazyeval/homebrew-tap.git" "$work" + mkdir -p "$work/Formula" + cp /tmp/rdbms-playground.rb "$work/Formula/rdbms-playground.rb" + + cd "$work" + git config user.name "lazyeval-ci" + git config user.email "ci@lazyeval.net" + git add Formula/rdbms-playground.rb + if git diff --cached --quiet; then + echo "homebrew: formula already at $VER — nothing to commit." + exit 0 + fi + git commit -m "rdbms-playground $VER" + # Push to main explicitly: a freshly-created (empty) repo clone may put + # the first commit on a differently-named local branch. Assumes the + # bucket/tap default branch is `main` (Gitea's default for new repos). + git push origin HEAD:main + echo "homebrew: tap updated to rdbms-playground $VER." + + # winget remains a future sibling job here (komac on Linux CI, or a manual PR + # to microsoft/winget-pkgs). No `needs:` between jobs — each is independent and + # idempotent, so one failing or being added never breaks another. diff --git a/docs/adr/0056-crates-io-and-cargo-binstall.md b/docs/adr/0056-crates-io-and-cargo-binstall.md index 63dc06f..32ab580 100644 --- a/docs/adr/0056-crates-io-and-cargo-binstall.md +++ b/docs/adr/0056-crates-io-and-cargo-binstall.md @@ -110,3 +110,69 @@ API pre-check + `cargo publish` as the backstop) — so future Scoop/Homebrew/winget jobs can be added alongside without breaking one another or re-runs. The first such job's `tag`-vs-`Cargo.toml` guard mirrors `release.yaml`. + +## Amendment 2 — 2026-06-19: Scoop bucket + Homebrew tap (D3 §3b/§3c) + +Two more package managers wired as **sibling `publish.yaml` jobs** +(`scoop-bucket`, `homebrew-tap`), following Amendment 1's independent + +idempotent pattern. Each fetches the release's `.sha256` sidecars, renders +a manifest, and commits it into a per-manager repo. + +**Repos — org-level and multi-package.** Both live under a new **`lazyeval` +Gitea organisation** (created with the `oli` account, which gives the +`git.lazyeval.net/lazyeval/...` paths): `lazyeval/scoop-bucket` and +`lazyeval/homebrew-tap`. A Scoop *bucket* and a Homebrew *tap* are by +definition **collections of manifests**, so these are reusable for future +tools, not single-package repos. Homebrew's `homebrew-` repo-name prefix is +mandatory (→ referenced as `lazyeval/tap`); Scoop's bucket name is free. +Users: `scoop bucket add lazyeval ` (the label is local/arbitrary; +only the URL owner is real) then `scoop install rdbms-playground`; and +`brew tap lazyeval/tap https://git.lazyeval.net/lazyeval/homebrew-tap` +(the explicit-URL form — the `user/repo` shorthand assumes GitHub) then +`brew install lazyeval/tap/rdbms-playground`. + +**Credential — a scoped bot user, not an `oli` PAT.** Gitea PATs scope by +**permission category, not per-repository** (`write:repository` grants +write to *every* repo the account can reach — there is no repo picker like +GitHub fine-grained PATs). So an `oli` token would also be able to push to +`oli/rdbms-playground` itself. Instead a dedicated bot user **`lazyeval-ci`** +is a member of a `lazyeval` org team with **Write** to the package repos +only; its `write:repository` PAT is therefore effectively scoped to those +repos and **cannot touch the main project repo**. Stored as the +`LAZYEVAL_PKG_TOKEN` Actions secret on `oli/rdbms-playground` (where the +workflow runs — *not* an org secret, which wouldn't reach a user-repo +workflow; *not* on the target repos, which only receive pushes). Passed via +`env:` (never inlined), so it stays masked and only materialises in the +clone URL at runtime; pushes go to `HEAD:main` (assumes the repos default +to `main`). + +**Render scripts are dependency-free bash.** The CI job container is +`node:22-bookworm-slim` — **no jq, no ruby** — so +`scripts/render-{scoop-manifest,homebrew-formula}.sh` are pure bash +(heredocs, no external deps) taking a version + the relevant hashes and +emitting the manifest on stdout. `scripts/test-package-renders.sh` is their +test (JSON validated with `node` — present in the image — plus `jq`/`ruby` +when available; field-level assertions). The job validates the rendered +Scoop JSON with `node -e JSON.parse` before committing. + +**Manifest specifics.** +- *Scoop* (`rdbms-playground.json` at bucket root): `64bit` = + `x86_64-pc-windows-gnu.exe`, `arm64` = + `aarch64-pc-windows-gnullvm.exe`; each URL carries a + `#/rdbms-playground.exe` rename fragment so the `bin` shim resolves + regardless of version. Carries `checkver` (lets `scoop status` / the + community excavator see lag) but **no `autoupdate`** — our pipeline is the + updater. +- *Homebrew* (`Formula/rdbms-playground.rb`): `on_macos`/`on_linux` × + `on_arm`/`on_intel` selecting the four bare-binary assets (macOS direct; + Linux = the static `-musl` build). **Windows absent** — Homebrew has no + Windows port. `install` drops the single staged binary under a stable + name; the `test` block runs `--version`. + +**Unverified (validate on first real use):** an actual `scoop install` and +`brew install`/`brew test`; the `HEAD:main` default-branch assumption; and +whether macOS Gatekeeper accepts the **ad-hoc-signed** mac binary via +`brew` (execution should be fine — ad-hoc satisfies arm64's signing +requirement and `brew`'s curl download sets no quarantine xattr, unlike a +browser download — but this rides on the still-parked Developer-ID signing +decision). **Remaining D3:** winget (komac on Linux CI, or a manual PR). diff --git a/docs/adr/README.md b/docs/adr/README.md index 37160c7..d9ca677 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -68,4 +68,4 @@ This directory contains the project's ADRs, recorded per - [ADR-0053 — Contextual `hint` command and keybinding](0053-contextual-hint-command-and-keybinding.md) — **Accepted, implemented 2026-06-15** (Phases A–D; closes **A1** + requirements **H2**). Settles the `hint` slot ADR-0003 left "ADR pending"; closes the last open piece of **A1** and tracks requirements **H2**. **Two surfaces:** an **F1 keybinding** that renders a deep hint for the *live* partial input without submitting (the primary path — a submitted `hint` command can't see the buffer it would help with, since Enter empties it), and a submitted **`hint` command** that expands on the *most recent error*. **No topic argument** (contextual only — `help ` already owns explicit reference). Introduces a **tier-3 teaching layer**, deeper than the existing tier-1 (colour / error headline) and tier-2 (ambient one-liner; and the error `hint:`, which is shown **by default** since `Verbosity::Verbose` is the default — `messages short` is the opt-*out*); without it `hint` would just duplicate what's already on screen. Tier-3 content lives in the catalogue under `hint.cmd.` (per command form) and `hint.err.` (per error/diagnostic class), each a structured `what`/`example`/`concept` block rendered via a new `note_hint*` family with `OutputStyleClass::Hint`. **Keyed per-form via a new `hint_ids: &[&str]` field on `CommandNode` mirroring `usage_ids`** (revised in Phase B): a per-*node* key proved too coarse — `add`/`drop`/`show`/`create` are each one node spanning many forms, and a live-input hint for `add 1:n relationship` must be specific to relationships; `hint_key_for_input_in_mode` reuses `usage_key_for_input_in_mode`'s form-word disambiguation, and covers the advanced-SQL forms whose `usage_ids` are empty. Not keyed off `help_id` (it is `None` on the advanced-SQL nodes purely to dedup the `help` list; that parallel gap is issue **#36**). **Clause-concept hints** (`on delete` actions, constraint slots, `with pk`, cardinality) are a recorded **deferred extension** (`hint.concept.`, issue **#37**) — per-form is the right tier-3 granularity, with position-awareness owned by tier-2 + the live `Next:` line. Runtime `translate_error` classes resolve via stored `last_error_hint_key` (`hint` command / empty-F1). (The second route — pre-submit `diagnostic.*` read live from the walker on the F1 path — is **deferred**, issue **#38**: `Diagnostic` carries no class key.) Adds `AppCommand::Hint`, a `HINT` grammar node + REGISTRY entry, the `hint_ids` field, and `last_error_hint_key`; F1 is a read-only overlay (buffer + completion memo untouched). **Content is the bulk of the work** (the mechanism is ~a day): v1 scope = ~37 command forms + 9 runtime error classes (comprehensive for those, ~57 blocks), authored **exemplars-first** (voice approved in this ADR's `/runda` review, then mass-authored in batches), enforced by a **comprehensiveness coverage test**, with graceful fall-back to tier-2 if a key is ever missing. The **pre-submit-diagnostic route + ~33 `diagnostic.*` blocks were deferred** (issue **#38**) — `Diagnostic` carries no class key, so the route needs a broad change for marginal value (tier-2 already surfaces diagnostics; many duplicate runtime classes). Forks user-chosen: two-surface model; **F1** (vs `?` / a chord); no-arg; comprehensive-for-commands-and-errors scope; exemplars-first; diagnostics deferred. OOS: per-topic `hint ` (rejected — overlaps `help`); always-on tier-3 (rejected — keeps ambient terse); non-`en-US` locales + success-command teaching (deferred); clause-concept hints (issue #37); the diagnostic route (issue #38); the `help`-side advanced-SQL gap (issue #36) - [ADR-0054 — Release versioning policy + version surfaces (`--version` / `version`)](0054-release-versioning-and-version-surfaces.md) — **Accepted + implemented 2026-06-16** (plan: `docs/plans/20260616-public-availability.md`, step 1 on the road to public availability; no prior issue/`requirements.md` item — an untracked gap). Fixes the **tag↔crate-version decoupling**: `Cargo.toml` built `0.1.0` while `release.yaml` named assets from the git tag, so a binary could report a version different from the asset it shipped in. **Decision:** `Cargo.toml` `version` is the **single source of truth** (read via `env!("CARGO_PKG_VERSION")`, no tag-injection); two surfaces report it through one `cli::version_text()` → catalog `cli.version_line` — a **`--version` / `-V`** CLI flag (mirrors `--help`, prints+exits in `main.rs`) and an in-app **`version`** command (REGISTRY node `app::VERSION`, `AppCommand::Version`, emits via `note_system`); and a **release-CI version guard** (`release.yaml` `test` job reads the `[package]` version from `Cargo.toml` and **fails the release** unless the `v*` tag equals `v`; the guard's parse was later switched from `cargo metadata | node` to a `grep` on Cargo.toml after the former broke on the flake devShell's stdout banner). Release ritual: bump `Cargo.toml` → commit → tag → push. New keys `cli.version_line` + `help.app.version` + `parse.usage.version` + `hint.cmd.version.{what,example}` (the new REGISTRY command pulls in the comprehensiveness coverage gate). Rejected: tag-as-source (makes Cargo.toml lie). Deferred: git-hash/build-date enrichment (behind the same `version_text()` seam); UI placement beyond the command. Tested test-first: CLI parse (`--version`/`-V`/default-off), `version_text()` carries `CARGO_PKG_VERSION`, the in-app command parses + emits. Also corrected a stale `release.yaml` header comment ("macOS is deferred" → built by the dispatched `release-macos.yaml`). - [ADR-0055 — `curl | sh` install script (`scripts/install.sh`)](0055-curl-sh-install-script.md) — **Accepted + implemented 2026-06-17** (plan: `docs/plans/20260616-public-availability.md`, step 2; tracked by plan + ADR, no Gitea issue — user decision). A one-line installer (`curl -fsSL /scripts/install.sh | sh`) so beginners don't hand-pick an asset + `chmod +x`. **POSIX `sh`** (shellcheck-clean), detects `uname` OS/arch → target triple (**Linux → the fully-static `*-musl`** build, macOS → `*-apple-darwin`; `amd64`/`arm64` aliased; **Windows rejected** → Scoop/winget/releases page), resolves the version from the **`releases/latest`** API (or `RDBMS_VERSION` to pin), downloads the asset **and its `.sha256` and verifies it** (mismatch aborts), installs to `~/.local/bin` (`RDBMS_INSTALL_DIR` override) with a PATH hint. Testing seams: `RDBMS_OS`/`RDBMS_ARCH` + `--print-target`. macOS note: `curl` downloads aren't Gatekeeper-quarantined so the ad-hoc binary runs as-is (Developer-ID + notarization is the postponed signing task). **Verified end-to-end against the live public `v0.1.0`** (all platform mappings, pinned + latest, checksum incl. tamper-rejection, install + run). Rejected: website-domain hosting (extra moving part; Gitea raw is simplest); deferred: uploading the script as a release asset, and a **shellcheck CI gate** (shellcheck isn't in the flake — touches ADR-ci-002). **Amendment 1 (2026-06-17):** added a Windows **`scripts/install.ps1`** (`irm | iex`; maps host CPU → our `*-windows-gnu`/`-gnullvm` `.exe`, SHA-256-verifies, installs to `%LOCALAPPDATA%\Programs\…` + user PATH) — user chose both a one-liner *and* Scoop/winget; **written but untested from this env** (no PowerShell — validate on Windows). -- [ADR-0056 — crates.io publish-readiness + `cargo binstall` metadata (D3)](0056-crates-io-and-cargo-binstall.md) — **Prepared 2026-06-17** (plan step 3a; tracked by plan + ADR). Makes the crate **ready to publish** to crates.io (user decision) and adds `cargo-binstall` metadata; the actual `cargo publish` is a **gated, irreversible maintainer step**. Manifest: drops `publish = false`; adds `homepage` (relplay.org), `keywords`, `categories`, and an `exclude` (`/website`,`/docs`,`/.gitea`,`/.codegraph`) trimming the crate from 585 files/8.3 MiB → **353/913 KiB compressed** (code-only). Authors **`README.md`** (engine-neutral, simple/advanced-mode wording; install via curl|sh/binstall/source/prebuilt) and **`LICENSE-MIT`** (© Lazy Evaluation Ltd — *confirm holder*); the canonical **`LICENSE-APACHE`** is deferred to the maintainer (don't ship retyped legal text) — the SPDX `license` field already satisfies crates.io. **binstall** (syntax verified vs cargo-binstall SUPPORT.md): `pkg-fmt = "bin"` (bare binaries), `pkg-url` spelled `v{ version }` (the placeholder omits the `v`), plus per-target **`overrides`** mapping the common host triples to the assets we ship — `*-linux-gnu` → the static `*-linux-musl` build, `*-pc-windows-msvc` → `*-gnu`/`-gnullvm` `.exe` (macOS matches directly; the docs promise no automatic fallback). **Ordering:** publish at a **new tagged version whose release exists**, after the release — **not `0.1.0`** (diverges from the already-released 0.1.0 binaries that predate `--version`). Verified: `cargo publish --dry-run` packages + verify-builds; `cargo metadata` confirms the binstall block + 4 overrides. **Unverified:** a real `cargo binstall` run (not a dep; nothing on crates.io yet) — validate at first publish. Rejected: cargo-dist (GitHub-centric). Maintainer follow-ups: confirm © holder, add canonical `LICENSE-APACHE`, real binstall validation. **Amendment 1 (2026-06-18):** `0.2.0` **published live** (crates.io; `cargo install` + `cargo binstall` verified — the unverified-overrides caveat is resolved), via a new **manual `workflow_dispatch`** workflow `.gitea/workflows/publish.yaml` (mirrors `release-macos.yaml`; `tag` input; `cargo publish` with a crate-scoped `CARGO_REGISTRY_TOKEN` secret). Publish stays **manual** by decision — irreversible (keeps the token off every tag push), the split release (tag Linux/Windows + dispatched macOS) makes a human the "all assets up" gate, and crates.io has no Gitea-Actions trusted-publishing path. Each registry is its **own idempotent job** (crates.io job no-ops if the version exists) so Scoop/Homebrew/winget can be added as sibling jobs without interfering. +- [ADR-0056 — crates.io publish-readiness + `cargo binstall` metadata (D3)](0056-crates-io-and-cargo-binstall.md) — **Prepared 2026-06-17** (plan step 3a; tracked by plan + ADR). Makes the crate **ready to publish** to crates.io (user decision) and adds `cargo-binstall` metadata; the actual `cargo publish` is a **gated, irreversible maintainer step**. Manifest: drops `publish = false`; adds `homepage` (relplay.org), `keywords`, `categories`, and an `exclude` (`/website`,`/docs`,`/.gitea`,`/.codegraph`) trimming the crate from 585 files/8.3 MiB → **353/913 KiB compressed** (code-only). Authors **`README.md`** (engine-neutral, simple/advanced-mode wording; install via curl|sh/binstall/source/prebuilt) and **`LICENSE-MIT`** (© Lazy Evaluation Ltd — *confirm holder*); the canonical **`LICENSE-APACHE`** is deferred to the maintainer (don't ship retyped legal text) — the SPDX `license` field already satisfies crates.io. **binstall** (syntax verified vs cargo-binstall SUPPORT.md): `pkg-fmt = "bin"` (bare binaries), `pkg-url` spelled `v{ version }` (the placeholder omits the `v`), plus per-target **`overrides`** mapping the common host triples to the assets we ship — `*-linux-gnu` → the static `*-linux-musl` build, `*-pc-windows-msvc` → `*-gnu`/`-gnullvm` `.exe` (macOS matches directly; the docs promise no automatic fallback). **Ordering:** publish at a **new tagged version whose release exists**, after the release — **not `0.1.0`** (diverges from the already-released 0.1.0 binaries that predate `--version`). Verified: `cargo publish --dry-run` packages + verify-builds; `cargo metadata` confirms the binstall block + 4 overrides. **Unverified:** a real `cargo binstall` run (not a dep; nothing on crates.io yet) — validate at first publish. Rejected: cargo-dist (GitHub-centric). Maintainer follow-ups: confirm © holder, add canonical `LICENSE-APACHE`, real binstall validation. **Amendment 1 (2026-06-18):** `0.2.0` **published live** (crates.io; `cargo install` + `cargo binstall` verified — the unverified-overrides caveat is resolved), via a new **manual `workflow_dispatch`** workflow `.gitea/workflows/publish.yaml` (mirrors `release-macos.yaml`; `tag` input; `cargo publish` with a crate-scoped `CARGO_REGISTRY_TOKEN` secret). Publish stays **manual** by decision — irreversible (keeps the token off every tag push), the split release (tag Linux/Windows + dispatched macOS) makes a human the "all assets up" gate, and crates.io has no Gitea-Actions trusted-publishing path. Each registry is its **own idempotent job** (crates.io job no-ops if the version exists) so Scoop/Homebrew/winget can be added as sibling jobs without interfering. **Amendment 2 (2026-06-19):** **Scoop + Homebrew wired** (D3 §3b/§3c) as sibling `publish.yaml` jobs (`scoop-bucket`, `homebrew-tap`) that render manifests from the release `.sha256` sidecars and push to **org-level, multi-package** repos `lazyeval/scoop-bucket` + `lazyeval/homebrew-tap`. Credential: a scoped bot user **`lazyeval-ci`** (Gitea PATs scope by permission-category, not per-repo, so an `oli` token would over-reach to the main repo) on a `lazyeval` org team with Write to the package repos only; its PAT is the `LAZYEVAL_PKG_TOKEN` secret on `oli/rdbms-playground`. Render scripts (`scripts/render-{scoop-manifest,homebrew-formula}.sh`) are **dependency-free bash** (CI image `node:22-slim` has no jq/ruby), tested by `scripts/test-package-renders.sh`. Scoop: `#/`-rename fragment + `checkver`, no `autoupdate`. Homebrew: `on_macos`/`on_linux`×arch bare-binary formula, no Windows. **Unverified:** real `scoop`/`brew install`, the `HEAD:main` branch assumption, macOS Gatekeeper-via-brew (ad-hoc sign). **Remaining D3:** winget. diff --git a/docs/requirements.md b/docs/requirements.md index a240981..2f35b28 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -85,11 +85,16 @@ since ADR-0027.) No target requires anything the user must install. ADR-ci-003.)* - [ ] **D3** Released via prebuilt binaries plus Homebrew, Scoop, `winget`, and `cargo binstall`. - *(Prebuilt binaries + checksums now published to Gitea releases - (D1); the package-manager manifests (Homebrew / Scoop / winget / - `cargo binstall`) remain to do. The asset naming - `rdbms-playground--` is already binstall-friendly. - Tracked under ADR-ci-003 "Deferred".)* + *(Prebuilt binaries + checksums on Gitea releases (D1); **`cargo + binstall` + crates.io live** (ADR-0056); **Scoop + Homebrew wired** + (ADR-0056 Amendment 2) — `publish.yaml` `scoop-bucket` / + `homebrew-tap` jobs render dependency-free manifests from the release + `.sha256` sidecars and push them, via the scoped `lazyeval-ci` bot + token, to `lazyeval/scoop-bucket` and `lazyeval/homebrew-tap`; + rendering covered by `scripts/test-package-renders.sh`, end-to-end + install still to be user-verified. **Remaining: winget** (komac on + Linux CI, or a manual PR). Asset naming + `rdbms-playground--` is binstall-friendly.)* ## TUI shell diff --git a/scripts/render-homebrew-formula.sh b/scripts/render-homebrew-formula.sh new file mode 100755 index 0000000..fcd8644 --- /dev/null +++ b/scripts/render-homebrew-formula.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# +# Render the Homebrew formula for rdbms-playground to stdout. +# +# Pure function of its inputs — NO network, NO jq/ruby — so it runs unchanged in +# the CI job container (node:22-bookworm-slim: bash + coreutils only). Given a +# version and the four macOS/Linux asset SHA-256 hashes it prints a complete +# formula. The publish.yaml `homebrew-tap` job fetches the hashes from the +# release .sha256 sidecars and commits the result into lazyeval/homebrew-tap as +# Formula/rdbms-playground.rb. +# +# The release assets are bare binaries (no archive), so Homebrew stages the +# single downloaded file in the build dir and `install` drops it under a stable +# name. Windows is intentionally absent — Homebrew has no Windows port (Scoop / +# winget cover Windows). +# +# Usage: render-homebrew-formula.sh +# version, with or without a leading 'v' +# sha256 of aarch64-apple-darwin +# sha256 of x86_64-apple-darwin +# sha256 of aarch64-unknown-linux-musl +# sha256 of x86_64-unknown-linux-musl +set -euo pipefail + +if [ "$#" -ne 5 ]; then + echo "usage: $0 " >&2 + exit 2 +fi + +version=${1#v} +mac_arm=$2 +mac_intel=$3 +linux_arm=$4 +linux_intel=$5 + +base="https://git.lazyeval.net/oli/rdbms-playground/releases/download/v$version" + +# Ruby interpolations (#{version}, #{bin}) must survive verbatim into the +# formula; they contain no '$', so this unquoted heredoc leaves them untouched +# and only expands the shell variables below. +cat < "rdbms-playground" + end + + test do + assert_match "rdbms-playground #{version}", shell_output("#{bin}/rdbms-playground --version") + end +end +EOF diff --git a/scripts/render-scoop-manifest.sh b/scripts/render-scoop-manifest.sh new file mode 100755 index 0000000..ae0e4f3 --- /dev/null +++ b/scripts/render-scoop-manifest.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# +# Render the Scoop manifest for rdbms-playground to stdout. +# +# Pure function of its inputs — NO network, NO jq/ruby — so it runs unchanged in +# the CI job container (node:22-bookworm-slim: bash + coreutils only). Given a +# version and the two Windows asset SHA-256 hashes it prints a complete, +# schema-valid Scoop manifest. The publish.yaml `scoop-bucket` job fetches the +# hashes from the release's .sha256 sidecars and commits the result into the +# lazyeval/scoop-bucket repository as rdbms-playground.json. +# +# Manifest updates are CI-driven (this script, per release), so the manifest +# carries `checkver` (so `scoop status` / the community excavator can see when +# the bucket lags upstream) but deliberately NO `autoupdate` — our pipeline is +# the updater, not Scoop's maintainer tooling. +# +# Usage: render-scoop-manifest.sh +# version, with or without a leading 'v' (e.g. 0.2.0 or v0.2.0) +# sha256 of the x86_64-pc-windows-gnu.exe asset +# sha256 of the aarch64-pc-windows-gnullvm.exe asset +set -euo pipefail + +if [ "$#" -ne 3 ]; then + echo "usage: $0 " >&2 + exit 2 +fi + +# Accept either 0.2.0 or v0.2.0; the manifest 'version' field is bare. +version=${1#v} +hash_x64=$2 +hash_arm64=$3 + +repo="https://git.lazyeval.net/oli/rdbms-playground" +base="$repo/releases/download/v$version" + +# The `#/rdbms-playground.exe` fragment tells Scoop to save the versioned asset +# under a stable filename, so the `bin` shim resolves regardless of version. +url_x64="$base/rdbms-playground-v$version-x86_64-pc-windows-gnu.exe#/rdbms-playground.exe" +url_arm64="$base/rdbms-playground-v$version-aarch64-pc-windows-gnullvm.exe#/rdbms-playground.exe" + +# Note: \$.tag_name emits a literal $ (Scoop's JSONPath); the regex uses [0-9.] +# rather than \d so the manifest contains no backslashes to escape. +cat <&2; exit 1; } +pass() { echo "ok: $*"; } + +# Distinct dummy hashes so we can assert each lands in the right slot. +VER=9.9.9 +H_WIN_X64=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa01 +H_WIN_ARM=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa02 +H_MAC_ARM=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa03 +H_MAC_X64=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa04 +H_LIN_ARM=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa05 +H_LIN_X64=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa06 + +# ---- Scoop ---------------------------------------------------------------- +scoop=$tmp/rdbms-playground.json +# Pass a leading 'v' to confirm it gets stripped. +"$here/render-scoop-manifest.sh" "v$VER" "$H_WIN_X64" "$H_WIN_ARM" > "$scoop" + +node -e 'JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"))' "$scoop" \ + || fail "scoop manifest is not valid JSON" +pass "scoop manifest parses as JSON" + +# Field-level assertions via node (no jq dependency). +check_json() { # + local got + got=$(node -e ' + const m = JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")); + process.stdout.write(String(eval(process.argv[2]))); + ' "$scoop" "$1") + [ "$got" = "$2" ] || fail "scoop: $1 = '$got', expected '$2'" +} +check_json 'm.version' "$VER" +check_json 'm.bin' "rdbms-playground.exe" +check_json 'm.architecture["64bit"].hash' "$H_WIN_X64" +check_json 'm.architecture["arm64"].hash' "$H_WIN_ARM" +check_json 'm.checkver.jsonpath' '$.tag_name' +grep -q "rdbms-playground-v$VER-x86_64-pc-windows-gnu.exe#/rdbms-playground.exe" "$scoop" \ + || fail "scoop: x64 url/fragment missing" +grep -q "rdbms-playground-v$VER-aarch64-pc-windows-gnullvm.exe#/rdbms-playground.exe" "$scoop" \ + || fail "scoop: arm64 url/fragment missing" +pass "scoop manifest fields correct" + +if command -v jq >/dev/null 2>&1; then + jq -e . "$scoop" >/dev/null || fail "scoop: jq rejected the manifest" + pass "scoop manifest valid per jq" +fi + +# ---- Homebrew ------------------------------------------------------------- +formula=$tmp/rdbms-playground.rb +"$here/render-homebrew-formula.sh" "$VER" "$H_MAC_ARM" "$H_MAC_X64" "$H_LIN_ARM" "$H_LIN_X64" > "$formula" + +if command -v ruby >/dev/null 2>&1; then + ruby -c "$formula" >/dev/null || fail "homebrew formula is not valid Ruby" + pass "homebrew formula parses as Ruby" +else + echo "warn: ruby not present — skipping formula syntax check" >&2 +fi + +# Each hash must appear exactly once (right asset → right slot), the version +# must be present, and #{version} must survive verbatim for brew's test block. +for pair in \ + "aarch64-apple-darwin:$H_MAC_ARM" \ + "x86_64-apple-darwin:$H_MAC_X64" \ + "aarch64-unknown-linux-musl:$H_LIN_ARM" \ + "x86_64-unknown-linux-musl:$H_LIN_X64"; do + target=${pair%%:*}; hash=${pair#*:} + grep -q "rdbms-playground-v$VER-$target\"" "$formula" || fail "homebrew: url for $target missing" + grep -q "sha256 \"$hash\"" "$formula" || fail "homebrew: sha256 for $target missing" +done +grep -q 'version "'"$VER"'"' "$formula" || fail "homebrew: version line missing" +grep -q 'assert_match "rdbms-playground #{version}"' "$formula" \ + || fail "homebrew: test block #{version} interpolation was mangled" +grep -q 'rdbms-playground-v9.9.9-x86_64-pc-windows' "$formula" \ + && fail "homebrew: formula unexpectedly references a Windows asset" +pass "homebrew formula fields correct" + +echo "all render tests passed" From 6bb2288470cb06d8241c5df8c882df3b9a291492 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Sat, 20 Jun 2026 08:15:17 +0000 Subject: [PATCH 18/20] docs(adr-0056): Scoop+Homebrew validated; record the Caddy HEAD fix Amendment 3: scoop install and brew install both verified end-to-end on real Windows + macOS (install + run), resolving Amendment 2's unverified caveats (incl. the HEAD:main push). Records the root cause + fix for the Homebrew 403: Gitea (>=1.25) 303-redirects release downloads to a method-bound SigV4 presigned S3 URL; brew resolves with HEAD, captures the HEAD-signed URL, then GETs it -> 403 (GET-only tools are unaffected). Fixed by a server-side Caddy rule that answers HEAD on release-download paths directly (200, no redirect) so the download GET re-runs the redirect fresh. That rule lives in the Gitea edge config, NOT this repo - documented so a server rebuild doesn't silently break brew. Includes the reference Caddyfile block. Also notes the ad-hoc mac signature runs fine via brew; Developer-ID + notarization stays parked on the Apple org conversion. Remaining D3: winget. --- docs/adr/0056-crates-io-and-cargo-binstall.md | 62 +++++++++++++++++++ docs/adr/README.md | 2 +- docs/requirements.md | 20 +++--- 3 files changed, 75 insertions(+), 9 deletions(-) diff --git a/docs/adr/0056-crates-io-and-cargo-binstall.md b/docs/adr/0056-crates-io-and-cargo-binstall.md index 32ab580..3985650 100644 --- a/docs/adr/0056-crates-io-and-cargo-binstall.md +++ b/docs/adr/0056-crates-io-and-cargo-binstall.md @@ -176,3 +176,65 @@ whether macOS Gatekeeper accepts the **ad-hoc-signed** mac binary via requirement and `brew`'s curl download sets no quarantine xattr, unlike a browser download — but this rides on the still-parked Developer-ID signing decision). **Remaining D3:** winget (komac on Linux CI, or a manual PR). + +## Amendment 3 — 2026-06-19: validated end-to-end; a presigned-URL/HEAD gotcha + the Caddy fix it needs + +**Scoop and Homebrew now install and run end-to-end** (`v0.2.0`, +user-verified on real Windows + macOS): `scoop install rdbms-playground` +and `brew install lazyeval/tap/rdbms-playground` both fetch, checksum, +install, and the installed binary launches. Amendment 2's "unverified" +caveats are resolved (the `HEAD:main` push populated both repos cleanly). + +**Distribution depends on a server-side Caddy rule — RECORD THIS: it lives +in the Gitea edge config, NOT this repo, and if it is lost in a server +rebuild Homebrew silently 403s again.** Symptom: `brew install` failed with +`curl (22) … 403` on the asset URL. Root cause: Gitea (≥1.25; here 1.26.2) +serves release-asset downloads by **303-redirecting to a method-bound +AWS-SigV4 presigned S3 URL** (OVH), signed for the *incoming* request's HTTP +verb. Homebrew **resolves the URL with a HEAD, captures the returned +(HEAD-signed) presigned URL, then runs the download GET against that +captured URL** → GET-on-a-HEAD-signed-URL → 403. Reproduced precisely: +`GET on HEAD-resolved = 403`, `HEAD on HEAD-resolved = 200`, `GET on +GET-resolved = 200`, `HEAD on GET-resolved = 403`. GET-only tools +(`install.sh`, `install.ps1`, `cargo binstall`, curl) are unaffected — they +GET the original URL and let it redirect fresh to a GET-signed URL. This is +a **Homebrew defect** (it reuses an ephemeral, method-scoped credential as +if it were a durable resource locator); Gitea's per-request method-bound +signing is correct, and SigV4 *cannot* sign one URL for two verbs. +`SERVE_DIRECT=false` (Gitea proxies, no presign) would also fix it but was +declined — not reshaping storage for one client. + +**Fix (deployed, "Tier A"): a Caddy rule that answers HEAD on +release-download paths directly** — 200, no redirect, no body — so brew's +resolve records the *original* URL (no presigned credential captured) and +its download GET runs through the redirect fresh → GET-signed → 200. Scoped +to `method HEAD` + the release-download path; **GET is untouched**, so every +working channel is unaffected, and a HEAD carries no payload so no client +can *break* (at most a HEAD-probing download manager loses a progress-bar +size — cosmetic). Reference Caddyfile (place before the Gitea +reverse_proxy): + +``` +@release_head { + method HEAD + path_regexp ^/[^/]+/[^/]+/releases/download/.+ +} +handle @release_head { + header Accept-Ranges bytes + header Content-Type application/octet-stream + respond 200 +} +``` + +A "Tier B" variant (a sidecar that fetches and returns the *real* +Content-Length so even HEAD-probing clients stay fully faithful) was specced +but proved unnecessary — brew is happy with the bare 200. + +**macOS signing:** the brew-installed binary **runs** under the current +**ad-hoc** signature (`codesign --sign -`) — confirmed on Apple Silicon. +**Developer-ID signing + notarization remains parked** pending the user's +Apple account conversion to an Organization (GA plan / ADR-ci-003); it is +needed for *browser-download* trust, not for the package-manager paths, +which are all working now. + +**Remaining D3:** winget only. diff --git a/docs/adr/README.md b/docs/adr/README.md index d9ca677..7658481 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -68,4 +68,4 @@ This directory contains the project's ADRs, recorded per - [ADR-0053 — Contextual `hint` command and keybinding](0053-contextual-hint-command-and-keybinding.md) — **Accepted, implemented 2026-06-15** (Phases A–D; closes **A1** + requirements **H2**). Settles the `hint` slot ADR-0003 left "ADR pending"; closes the last open piece of **A1** and tracks requirements **H2**. **Two surfaces:** an **F1 keybinding** that renders a deep hint for the *live* partial input without submitting (the primary path — a submitted `hint` command can't see the buffer it would help with, since Enter empties it), and a submitted **`hint` command** that expands on the *most recent error*. **No topic argument** (contextual only — `help ` already owns explicit reference). Introduces a **tier-3 teaching layer**, deeper than the existing tier-1 (colour / error headline) and tier-2 (ambient one-liner; and the error `hint:`, which is shown **by default** since `Verbosity::Verbose` is the default — `messages short` is the opt-*out*); without it `hint` would just duplicate what's already on screen. Tier-3 content lives in the catalogue under `hint.cmd.` (per command form) and `hint.err.` (per error/diagnostic class), each a structured `what`/`example`/`concept` block rendered via a new `note_hint*` family with `OutputStyleClass::Hint`. **Keyed per-form via a new `hint_ids: &[&str]` field on `CommandNode` mirroring `usage_ids`** (revised in Phase B): a per-*node* key proved too coarse — `add`/`drop`/`show`/`create` are each one node spanning many forms, and a live-input hint for `add 1:n relationship` must be specific to relationships; `hint_key_for_input_in_mode` reuses `usage_key_for_input_in_mode`'s form-word disambiguation, and covers the advanced-SQL forms whose `usage_ids` are empty. Not keyed off `help_id` (it is `None` on the advanced-SQL nodes purely to dedup the `help` list; that parallel gap is issue **#36**). **Clause-concept hints** (`on delete` actions, constraint slots, `with pk`, cardinality) are a recorded **deferred extension** (`hint.concept.`, issue **#37**) — per-form is the right tier-3 granularity, with position-awareness owned by tier-2 + the live `Next:` line. Runtime `translate_error` classes resolve via stored `last_error_hint_key` (`hint` command / empty-F1). (The second route — pre-submit `diagnostic.*` read live from the walker on the F1 path — is **deferred**, issue **#38**: `Diagnostic` carries no class key.) Adds `AppCommand::Hint`, a `HINT` grammar node + REGISTRY entry, the `hint_ids` field, and `last_error_hint_key`; F1 is a read-only overlay (buffer + completion memo untouched). **Content is the bulk of the work** (the mechanism is ~a day): v1 scope = ~37 command forms + 9 runtime error classes (comprehensive for those, ~57 blocks), authored **exemplars-first** (voice approved in this ADR's `/runda` review, then mass-authored in batches), enforced by a **comprehensiveness coverage test**, with graceful fall-back to tier-2 if a key is ever missing. The **pre-submit-diagnostic route + ~33 `diagnostic.*` blocks were deferred** (issue **#38**) — `Diagnostic` carries no class key, so the route needs a broad change for marginal value (tier-2 already surfaces diagnostics; many duplicate runtime classes). Forks user-chosen: two-surface model; **F1** (vs `?` / a chord); no-arg; comprehensive-for-commands-and-errors scope; exemplars-first; diagnostics deferred. OOS: per-topic `hint ` (rejected — overlaps `help`); always-on tier-3 (rejected — keeps ambient terse); non-`en-US` locales + success-command teaching (deferred); clause-concept hints (issue #37); the diagnostic route (issue #38); the `help`-side advanced-SQL gap (issue #36) - [ADR-0054 — Release versioning policy + version surfaces (`--version` / `version`)](0054-release-versioning-and-version-surfaces.md) — **Accepted + implemented 2026-06-16** (plan: `docs/plans/20260616-public-availability.md`, step 1 on the road to public availability; no prior issue/`requirements.md` item — an untracked gap). Fixes the **tag↔crate-version decoupling**: `Cargo.toml` built `0.1.0` while `release.yaml` named assets from the git tag, so a binary could report a version different from the asset it shipped in. **Decision:** `Cargo.toml` `version` is the **single source of truth** (read via `env!("CARGO_PKG_VERSION")`, no tag-injection); two surfaces report it through one `cli::version_text()` → catalog `cli.version_line` — a **`--version` / `-V`** CLI flag (mirrors `--help`, prints+exits in `main.rs`) and an in-app **`version`** command (REGISTRY node `app::VERSION`, `AppCommand::Version`, emits via `note_system`); and a **release-CI version guard** (`release.yaml` `test` job reads the `[package]` version from `Cargo.toml` and **fails the release** unless the `v*` tag equals `v`; the guard's parse was later switched from `cargo metadata | node` to a `grep` on Cargo.toml after the former broke on the flake devShell's stdout banner). Release ritual: bump `Cargo.toml` → commit → tag → push. New keys `cli.version_line` + `help.app.version` + `parse.usage.version` + `hint.cmd.version.{what,example}` (the new REGISTRY command pulls in the comprehensiveness coverage gate). Rejected: tag-as-source (makes Cargo.toml lie). Deferred: git-hash/build-date enrichment (behind the same `version_text()` seam); UI placement beyond the command. Tested test-first: CLI parse (`--version`/`-V`/default-off), `version_text()` carries `CARGO_PKG_VERSION`, the in-app command parses + emits. Also corrected a stale `release.yaml` header comment ("macOS is deferred" → built by the dispatched `release-macos.yaml`). - [ADR-0055 — `curl | sh` install script (`scripts/install.sh`)](0055-curl-sh-install-script.md) — **Accepted + implemented 2026-06-17** (plan: `docs/plans/20260616-public-availability.md`, step 2; tracked by plan + ADR, no Gitea issue — user decision). A one-line installer (`curl -fsSL /scripts/install.sh | sh`) so beginners don't hand-pick an asset + `chmod +x`. **POSIX `sh`** (shellcheck-clean), detects `uname` OS/arch → target triple (**Linux → the fully-static `*-musl`** build, macOS → `*-apple-darwin`; `amd64`/`arm64` aliased; **Windows rejected** → Scoop/winget/releases page), resolves the version from the **`releases/latest`** API (or `RDBMS_VERSION` to pin), downloads the asset **and its `.sha256` and verifies it** (mismatch aborts), installs to `~/.local/bin` (`RDBMS_INSTALL_DIR` override) with a PATH hint. Testing seams: `RDBMS_OS`/`RDBMS_ARCH` + `--print-target`. macOS note: `curl` downloads aren't Gatekeeper-quarantined so the ad-hoc binary runs as-is (Developer-ID + notarization is the postponed signing task). **Verified end-to-end against the live public `v0.1.0`** (all platform mappings, pinned + latest, checksum incl. tamper-rejection, install + run). Rejected: website-domain hosting (extra moving part; Gitea raw is simplest); deferred: uploading the script as a release asset, and a **shellcheck CI gate** (shellcheck isn't in the flake — touches ADR-ci-002). **Amendment 1 (2026-06-17):** added a Windows **`scripts/install.ps1`** (`irm | iex`; maps host CPU → our `*-windows-gnu`/`-gnullvm` `.exe`, SHA-256-verifies, installs to `%LOCALAPPDATA%\Programs\…` + user PATH) — user chose both a one-liner *and* Scoop/winget; **written but untested from this env** (no PowerShell — validate on Windows). -- [ADR-0056 — crates.io publish-readiness + `cargo binstall` metadata (D3)](0056-crates-io-and-cargo-binstall.md) — **Prepared 2026-06-17** (plan step 3a; tracked by plan + ADR). Makes the crate **ready to publish** to crates.io (user decision) and adds `cargo-binstall` metadata; the actual `cargo publish` is a **gated, irreversible maintainer step**. Manifest: drops `publish = false`; adds `homepage` (relplay.org), `keywords`, `categories`, and an `exclude` (`/website`,`/docs`,`/.gitea`,`/.codegraph`) trimming the crate from 585 files/8.3 MiB → **353/913 KiB compressed** (code-only). Authors **`README.md`** (engine-neutral, simple/advanced-mode wording; install via curl|sh/binstall/source/prebuilt) and **`LICENSE-MIT`** (© Lazy Evaluation Ltd — *confirm holder*); the canonical **`LICENSE-APACHE`** is deferred to the maintainer (don't ship retyped legal text) — the SPDX `license` field already satisfies crates.io. **binstall** (syntax verified vs cargo-binstall SUPPORT.md): `pkg-fmt = "bin"` (bare binaries), `pkg-url` spelled `v{ version }` (the placeholder omits the `v`), plus per-target **`overrides`** mapping the common host triples to the assets we ship — `*-linux-gnu` → the static `*-linux-musl` build, `*-pc-windows-msvc` → `*-gnu`/`-gnullvm` `.exe` (macOS matches directly; the docs promise no automatic fallback). **Ordering:** publish at a **new tagged version whose release exists**, after the release — **not `0.1.0`** (diverges from the already-released 0.1.0 binaries that predate `--version`). Verified: `cargo publish --dry-run` packages + verify-builds; `cargo metadata` confirms the binstall block + 4 overrides. **Unverified:** a real `cargo binstall` run (not a dep; nothing on crates.io yet) — validate at first publish. Rejected: cargo-dist (GitHub-centric). Maintainer follow-ups: confirm © holder, add canonical `LICENSE-APACHE`, real binstall validation. **Amendment 1 (2026-06-18):** `0.2.0` **published live** (crates.io; `cargo install` + `cargo binstall` verified — the unverified-overrides caveat is resolved), via a new **manual `workflow_dispatch`** workflow `.gitea/workflows/publish.yaml` (mirrors `release-macos.yaml`; `tag` input; `cargo publish` with a crate-scoped `CARGO_REGISTRY_TOKEN` secret). Publish stays **manual** by decision — irreversible (keeps the token off every tag push), the split release (tag Linux/Windows + dispatched macOS) makes a human the "all assets up" gate, and crates.io has no Gitea-Actions trusted-publishing path. Each registry is its **own idempotent job** (crates.io job no-ops if the version exists) so Scoop/Homebrew/winget can be added as sibling jobs without interfering. **Amendment 2 (2026-06-19):** **Scoop + Homebrew wired** (D3 §3b/§3c) as sibling `publish.yaml` jobs (`scoop-bucket`, `homebrew-tap`) that render manifests from the release `.sha256` sidecars and push to **org-level, multi-package** repos `lazyeval/scoop-bucket` + `lazyeval/homebrew-tap`. Credential: a scoped bot user **`lazyeval-ci`** (Gitea PATs scope by permission-category, not per-repo, so an `oli` token would over-reach to the main repo) on a `lazyeval` org team with Write to the package repos only; its PAT is the `LAZYEVAL_PKG_TOKEN` secret on `oli/rdbms-playground`. Render scripts (`scripts/render-{scoop-manifest,homebrew-formula}.sh`) are **dependency-free bash** (CI image `node:22-slim` has no jq/ruby), tested by `scripts/test-package-renders.sh`. Scoop: `#/`-rename fragment + `checkver`, no `autoupdate`. Homebrew: `on_macos`/`on_linux`×arch bare-binary formula, no Windows. **Unverified:** real `scoop`/`brew install`, the `HEAD:main` branch assumption, macOS Gatekeeper-via-brew (ad-hoc sign). **Remaining D3:** winget. +- [ADR-0056 — crates.io publish-readiness + `cargo binstall` metadata (D3)](0056-crates-io-and-cargo-binstall.md) — **Prepared 2026-06-17** (plan step 3a; tracked by plan + ADR). Makes the crate **ready to publish** to crates.io (user decision) and adds `cargo-binstall` metadata; the actual `cargo publish` is a **gated, irreversible maintainer step**. Manifest: drops `publish = false`; adds `homepage` (relplay.org), `keywords`, `categories`, and an `exclude` (`/website`,`/docs`,`/.gitea`,`/.codegraph`) trimming the crate from 585 files/8.3 MiB → **353/913 KiB compressed** (code-only). Authors **`README.md`** (engine-neutral, simple/advanced-mode wording; install via curl|sh/binstall/source/prebuilt) and **`LICENSE-MIT`** (© Lazy Evaluation Ltd — *confirm holder*); the canonical **`LICENSE-APACHE`** is deferred to the maintainer (don't ship retyped legal text) — the SPDX `license` field already satisfies crates.io. **binstall** (syntax verified vs cargo-binstall SUPPORT.md): `pkg-fmt = "bin"` (bare binaries), `pkg-url` spelled `v{ version }` (the placeholder omits the `v`), plus per-target **`overrides`** mapping the common host triples to the assets we ship — `*-linux-gnu` → the static `*-linux-musl` build, `*-pc-windows-msvc` → `*-gnu`/`-gnullvm` `.exe` (macOS matches directly; the docs promise no automatic fallback). **Ordering:** publish at a **new tagged version whose release exists**, after the release — **not `0.1.0`** (diverges from the already-released 0.1.0 binaries that predate `--version`). Verified: `cargo publish --dry-run` packages + verify-builds; `cargo metadata` confirms the binstall block + 4 overrides. **Unverified:** a real `cargo binstall` run (not a dep; nothing on crates.io yet) — validate at first publish. Rejected: cargo-dist (GitHub-centric). Maintainer follow-ups: confirm © holder, add canonical `LICENSE-APACHE`, real binstall validation. **Amendment 1 (2026-06-18):** `0.2.0` **published live** (crates.io; `cargo install` + `cargo binstall` verified — the unverified-overrides caveat is resolved), via a new **manual `workflow_dispatch`** workflow `.gitea/workflows/publish.yaml` (mirrors `release-macos.yaml`; `tag` input; `cargo publish` with a crate-scoped `CARGO_REGISTRY_TOKEN` secret). Publish stays **manual** by decision — irreversible (keeps the token off every tag push), the split release (tag Linux/Windows + dispatched macOS) makes a human the "all assets up" gate, and crates.io has no Gitea-Actions trusted-publishing path. Each registry is its **own idempotent job** (crates.io job no-ops if the version exists) so Scoop/Homebrew/winget can be added as sibling jobs without interfering. **Amendment 2 (2026-06-19):** **Scoop + Homebrew wired** (D3 §3b/§3c) as sibling `publish.yaml` jobs (`scoop-bucket`, `homebrew-tap`) that render manifests from the release `.sha256` sidecars and push to **org-level, multi-package** repos `lazyeval/scoop-bucket` + `lazyeval/homebrew-tap`. Credential: a scoped bot user **`lazyeval-ci`** (Gitea PATs scope by permission-category, not per-repo, so an `oli` token would over-reach to the main repo) on a `lazyeval` org team with Write to the package repos only; its PAT is the `LAZYEVAL_PKG_TOKEN` secret on `oli/rdbms-playground`. Render scripts (`scripts/render-{scoop-manifest,homebrew-formula}.sh`) are **dependency-free bash** (CI image `node:22-slim` has no jq/ruby), tested by `scripts/test-package-renders.sh`. Scoop: `#/`-rename fragment + `checkver`, no `autoupdate`. Homebrew: `on_macos`/`on_linux`×arch bare-binary formula, no Windows. **Unverified:** real `scoop`/`brew install`, the `HEAD:main` branch assumption, macOS Gatekeeper-via-brew (ad-hoc sign). **Remaining D3:** winget. **Amendment 3 (2026-06-19):** **Scoop + Homebrew validated end-to-end** on real Windows + macOS (install + run). Found + fixed a **presigned-URL/HEAD gotcha**: Gitea (≥1.25) 303-redirects release downloads to a **method-bound SigV4 presigned S3 URL**; Homebrew resolves via HEAD, captures the HEAD-signed URL, then GETs it → 403 (GET-only tools unaffected). Fix is a **server-side Caddy rule** (lives in the Gitea edge config, NOT this repo — record it: lost on a rebuild → brew 403s again) answering HEAD on `…/releases/download/…` directly (200, no redirect) so brew's download GET re-runs the redirect fresh. ad-hoc mac signature **runs** via brew; Developer-ID + notarization still parked on the Apple org conversion. **Remaining D3:** winget only. diff --git a/docs/requirements.md b/docs/requirements.md index 2f35b28..5beb6ee 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -86,14 +86,18 @@ since ADR-0027.) - [ ] **D3** Released via prebuilt binaries plus Homebrew, Scoop, `winget`, and `cargo binstall`. *(Prebuilt binaries + checksums on Gitea releases (D1); **`cargo - binstall` + crates.io live** (ADR-0056); **Scoop + Homebrew wired** - (ADR-0056 Amendment 2) — `publish.yaml` `scoop-bucket` / - `homebrew-tap` jobs render dependency-free manifests from the release - `.sha256` sidecars and push them, via the scoped `lazyeval-ci` bot - token, to `lazyeval/scoop-bucket` and `lazyeval/homebrew-tap`; - rendering covered by `scripts/test-package-renders.sh`, end-to-end - install still to be user-verified. **Remaining: winget** (komac on - Linux CI, or a manual PR). Asset naming + binstall` + crates.io live** (ADR-0056); **Scoop + Homebrew wired and + validated end-to-end** on real Windows + macOS (ADR-0056 Amendments + 2 & 3) — `publish.yaml` `scoop-bucket` / `homebrew-tap` jobs render + dependency-free manifests from the release `.sha256` sidecars and push + them, via the scoped `lazyeval-ci` bot token, to + `lazyeval/scoop-bucket` and `lazyeval/homebrew-tap`; rendering covered + by `scripts/test-package-renders.sh`. NB: the Homebrew path depends on + a **server-side Caddy rule** answering HEAD on release-download paths + directly (Gitea's presigned-S3 redirect is method-bound and brew + reuses a HEAD-signed URL for its GET) — see ADR-0056 Amendment 3; + that rule lives in the Gitea edge config, not this repo. **Remaining: + winget** (komac on Linux CI, or a manual PR). Asset naming `rdbms-playground--` is binstall-friendly.)* ## TUI shell From 0208c67e59eb26fd5f1b70db02ad19f84e8e2901 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Sun, 21 Jun 2026 21:58:47 +0000 Subject: [PATCH 19/20] =?UTF-8?q?ci(publish):=20wire=20winget=20job=20via?= =?UTF-8?q?=20komac=20(D3=20=C2=A73d)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the 4th publish.yaml sibling job: opens a PR to microsoft/winget-pkgs with komac (pinned 2.16.0 prebuilt) for LazyEvaluation.RdbmsPlayground (portable, x64 + arm64). Unlike scoop/homebrew it's a PR into Microsoft's central, human-gated catalog - async and validated on their side. - Auth: a classic public_repo GitHub PAT on a dedicated bot account (lazyeval-ci; fine-grained tokens can't open the cross-fork PR - komac #310), as the WINGET_GITHUB_TOKEN secret, job-scoped and passed to the API guards via a 0600 curl config file (never argv). - Idempotent via two guards before submitting (already-merged version + already-open PR) so a repeated publish dispatch can't open a duplicate. - No actions/checkout (komac works off URLs + the GitHub API). Docs: ADR-0056 Amendment 4 - the model, the one-time manual `komac new` bootstrap recipe (flags verified vs komac 2.16.0), and first-run learnings: the fork must pre-exist, and a direct single-exe portable takes its PATH alias from Commands[0] (not PortableCommandAlias, which is nested-only). Plus README index + requirements D3. Wiring only; going live needs the bootstrap PR (#391335, submitted) to merge. --- .gitea/workflows/publish.yaml | 87 ++++++++++++++++++- docs/adr/0056-crates-io-and-cargo-binstall.md | 79 +++++++++++++++++ docs/adr/README.md | 2 +- docs/requirements.md | 10 ++- 4 files changed, 171 insertions(+), 7 deletions(-) diff --git a/.gitea/workflows/publish.yaml b/.gitea/workflows/publish.yaml index 3383b71..f58dd92 100644 --- a/.gitea/workflows/publish.yaml +++ b/.gitea/workflows/publish.yaml @@ -198,6 +198,87 @@ jobs: git push origin HEAD:main echo "homebrew: tap updated to rdbms-playground $VER." - # winget remains a future sibling job here (komac on Linux CI, or a manual PR - # to microsoft/winget-pkgs). No `needs:` between jobs — each is independent and - # idempotent, so one failing or being added never breaks another. + # Update the winget package (Windows) by opening a PR to microsoft/winget-pkgs + # with komac. Unlike scoop-bucket/homebrew-tap (which push to OUR repos and are + # live at once), winget is a PR into Microsoft's central, human-gated catalog — + # asynchronous, and re-submitting the same version would open a DUPLICATE PR. So + # this job guards on both already-merged versions AND already-open PRs before + # submitting, which keeps a repeated `publish` dispatch safe. + # + # Auth: komac needs a CLASSIC GitHub PAT with `public_repo` (fine-grained tokens + # cannot open the cross-fork PR — komac #310). It is held on a dedicated GitHub + # bot account (a leak can't reach other repos — same reasoning as lazyeval-ci) + # and referenced ONLY in this job's env (job-level secret scoping keeps it out + # of the other publish jobs). + # + # PREREQUISITE: the package `LazyEvaluation.RdbmsPlayground` must already exist + # in winget-pkgs via a one-time `komac new` (interactive — run manually once; + # see ADR-0056 Amendment 4). This job only does the per-release `komac update`. + winget: + runs-on: ci-public + container: + image: git.lazyeval.net/oli/rdbms-playground-ci:latest + steps: + - name: submit the winget update PR (idempotent) + shell: bash + env: + TAG: ${{ inputs.tag }} + # Classic public_repo PAT for the winget bot account. komac reads it + # from GITHUB_TOKEN; the API guards below read it via a curl config file + # so the token never appears in a command line / process list. + GITHUB_TOKEN: ${{ secrets.WINGET_GITHUB_TOKEN }} + run: | + set -euo pipefail + VER="${TAG#v}" + PKG="LazyEvaluation.RdbmsPlayground" + echo "winget: targeting $PKG $VER ($TAG)" + + # Auth header in a 0600 curl config (keeps the token out of argv/logs). + umask 077 + printf 'header = "Authorization: Bearer %s"\nheader = "Accept: application/vnd.github+json"\n' \ + "$GITHUB_TOKEN" > /tmp/gh-curlrc + api="https://api.github.com" + + # Guard 1 — already merged into winget-pkgs? + # (manifests////) + merged=$(curl -sS -o /dev/null -w '%{http_code}' --config /tmp/gh-curlrc \ + "$api/repos/microsoft/winget-pkgs/contents/manifests/l/LazyEvaluation/RdbmsPlayground/$VER" || echo 000) + if [ "$merged" = "200" ]; then + echo "winget: $PKG $VER already in winget-pkgs — nothing to do." + exit 0 + fi + + # Guard 2 — an open PR for this exact id+version already? (avoid a dup) + # curl -G --data-urlencode does the URL-encoding (no jq in the image). + curl -sS -G --config /tmp/gh-curlrc \ + --data-urlencode "q=repo:microsoft/winget-pkgs type:pr state:open in:title \"$PKG\" \"$VER\"" \ + "$api/search/issues" -o /tmp/winget-search.json + open=$(node -e 'process.stdout.write(String(JSON.parse(require("fs").readFileSync("/tmp/winget-search.json","utf8")).total_count||0))') + if [ "$open" != "0" ]; then + echo "winget: an open PR for $PKG $VER already exists — skipping." + exit 0 + fi + + # Install pinned komac (prebuilt glibc binary; the CI image has no + # cargo/komac). Pinned for reproducibility — bump deliberately. + KOMAC_VER=2.16.0 + curl -fsSL -o /tmp/komac.tgz \ + "https://github.com/russellbanks/Komac/releases/download/v$KOMAC_VER/komac-$KOMAC_VER-x86_64-unknown-linux-gnu.tar.gz" + tar -xzf /tmp/komac.tgz -C /tmp + komac_bin=$(find /tmp -maxdepth 2 -type f -name komac | head -1) + [ -n "$komac_bin" ] || { echo "ERROR: komac binary not found after extract" >&2; exit 1; } + + base="https://git.lazyeval.net/oli/rdbms-playground/releases/download/$TAG" + echo "winget: submitting update PR via komac $KOMAC_VER" + # NB: confirm flags against `komac update --help` on first run — komac + # evolves; --version/--urls/--submit are the stable core. komac infers + # architecture + the `portable` installer type from the binaries. + "$komac_bin" update "$PKG" \ + --version "$VER" \ + --urls "$base/rdbms-playground-$TAG-x86_64-pc-windows-gnu.exe" \ + "$base/rdbms-playground-$TAG-aarch64-pc-windows-gnullvm.exe" \ + --submit + echo "winget: update PR submitted for $PKG $VER (Microsoft review is async)." + + # No `needs:` between jobs — each is independent and idempotent, so one failing + # or being added never breaks another. diff --git a/docs/adr/0056-crates-io-and-cargo-binstall.md b/docs/adr/0056-crates-io-and-cargo-binstall.md index 3985650..b4784a1 100644 --- a/docs/adr/0056-crates-io-and-cargo-binstall.md +++ b/docs/adr/0056-crates-io-and-cargo-binstall.md @@ -238,3 +238,82 @@ needed for *browser-download* trust, not for the package-manager paths, which are all working now. **Remaining D3:** winget only. + +## Amendment 4 — 2026-06-20: winget (D3 §3d) — the last package manager + +winget wired as the fourth `publish.yaml` sibling job, **completing the D3 +package-manager set** (crates.io/binstall, Scoop, Homebrew, winget; plus the +install scripts + direct binaries). + +**Model — fundamentally unlike Scoop/Homebrew.** winget has no +self-hosted-source equivalent: its default catalog is the central, +**human-gated** GitHub repo `microsoft/winget-pkgs`, and you get in by +**opening a PR** of manifests into it. So the job submits a PR (via +**komac**, pinned `2.16.0`, prebuilt glibc binary — the CI image has no +cargo) and **Microsoft's pipeline validates async** (schema, SHA256, +AV/SmartScreen scan, sandbox install) + moderator review. It is *not* live +on dispatch like the bucket/tap. + +**PackageIdentifier `LazyEvaluation.RdbmsPlayground`** (publisher segment = +the publishing entity / `lazyeval` org / license holder — consistent with +everything else). Bare-exe → winget **`portable`** installer type; x64 +(`-pc-windows-gnu`) + arm64 (`-pc-windows-gnullvm`), komac infers arch + +type from the binaries. + +**Auth — a dedicated GitHub bot, classic token.** komac needs a **classic +`public_repo` PAT**; **fine-grained tokens cannot open the cross-fork PR** +(komac #310 — the PR is created on the *target* repo you don't administer, +which the fine-grained model can't express). A classic `public_repo` token +can't be scoped to one repo, so it lives on a **dedicated GitHub bot +account** (a leak can't reach other repos — the lazyeval-ci reasoning), as +the `WINGET_GITHUB_TOKEN` secret, referenced **only** in the `winget` job +(job-level scoping keeps it away from the crates.io / lazyeval tokens) and +passed to the API guards via a 0600 curl config file (never argv). + +**Idempotency — stronger than the others need.** A re-submitted version +would open a *duplicate* PR, so before submitting the job guards on **both** +(1) already-merged versions (`contents` API on +`manifests/l/LazyEvaluation/RdbmsPlayground/`) and (2) an already-open +PR for the id+version (`search/issues`). Either → clean skip, so a repeated +`publish` dispatch is safe even mid-review. + +**Signing:** none required to *submit* (only MSIX needs it; ours is +portable). The unsigned binary may earn an AV/SmartScreen **manual-review +label** on the first PR — usually clears; a nudge toward Trusted Signing +(the UK Ltd clears the 3-year-history bar) but not a blocker. Continues the +parked Developer-ID/notarization posture. + +**One-time bootstrap (manual — NOT in CI, because `komac new` is +interactive).** Run once to create the package in winget-pkgs, then CI +`komac update` handles every release after: + +``` +komac token update # paste the bot's classic public_repo PAT at the prompt (not in argv/history) +komac new LazyEvaluation.RdbmsPlayground \ + -v 0.2.0 \ + -u https://git.lazyeval.net/oli/rdbms-playground/releases/download/v0.2.0/rdbms-playground-v0.2.0-x86_64-pc-windows-gnu.exe \ + https://git.lazyeval.net/oli/rdbms-playground/releases/download/v0.2.0/rdbms-playground-v0.2.0-aarch64-pc-windows-gnullvm.exe \ + --publisher "Lazy Evaluation Ltd" --package-name "RDBMS Playground" \ + --moniker rdbms-playground --license "MIT OR Apache-2.0" \ + --package-url https://relplay.org -s +``` +komac downloads the two exes, detects the bare binary as **`portable`**, and +**prompts for the command alias — enter `rdbms-playground`** so users get +that on PATH (not the long versioned filename). `-s` opens the PR; fill any +remaining prompts. (Flags verified against komac 2.16.0: `-v/--version`, +`-u/--urls`, `-s/--submit`; the alias has no flag and is prompted.) Once +that first PR merges, the CI job's `komac update` takes over. + +**Bootstrap learnings (2026-06-21, first real run).** GitHub bot account = +`lazyeval-ci`. Its fork **`lazyeval-ci/winget-pkgs` must exist *before* +`komac new`** — komac's auto-fork races on winget-pkgs (one of GitHub's +largest repos) and fails with *"Could not resolve to a Repository +'lazyeval-ci/winget-pkgs'"*; fork it manually (web **Fork**, or `gh repo +fork microsoft/winget-pkgs --clone=false`), wait for it to populate, then +re-run. **Alias:** for a **direct single-exe portable**, winget derives the +on-PATH command from **`Commands[0]`** (the interactive "Commands" prompt → +`rdbms-playground`) — **not** `PortableCommandAlias`, which is +**archive/nested-portable-only** and must NOT be added to our manifest +(verified against winget-cli's portable-install logic + the 1.6.0 installer +schema; komac correctly omits it). So komac's generated manifest needs no +hand-editing. First PR: **#391335**. diff --git a/docs/adr/README.md b/docs/adr/README.md index 7658481..7d48835 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -68,4 +68,4 @@ This directory contains the project's ADRs, recorded per - [ADR-0053 — Contextual `hint` command and keybinding](0053-contextual-hint-command-and-keybinding.md) — **Accepted, implemented 2026-06-15** (Phases A–D; closes **A1** + requirements **H2**). Settles the `hint` slot ADR-0003 left "ADR pending"; closes the last open piece of **A1** and tracks requirements **H2**. **Two surfaces:** an **F1 keybinding** that renders a deep hint for the *live* partial input without submitting (the primary path — a submitted `hint` command can't see the buffer it would help with, since Enter empties it), and a submitted **`hint` command** that expands on the *most recent error*. **No topic argument** (contextual only — `help ` already owns explicit reference). Introduces a **tier-3 teaching layer**, deeper than the existing tier-1 (colour / error headline) and tier-2 (ambient one-liner; and the error `hint:`, which is shown **by default** since `Verbosity::Verbose` is the default — `messages short` is the opt-*out*); without it `hint` would just duplicate what's already on screen. Tier-3 content lives in the catalogue under `hint.cmd.` (per command form) and `hint.err.` (per error/diagnostic class), each a structured `what`/`example`/`concept` block rendered via a new `note_hint*` family with `OutputStyleClass::Hint`. **Keyed per-form via a new `hint_ids: &[&str]` field on `CommandNode` mirroring `usage_ids`** (revised in Phase B): a per-*node* key proved too coarse — `add`/`drop`/`show`/`create` are each one node spanning many forms, and a live-input hint for `add 1:n relationship` must be specific to relationships; `hint_key_for_input_in_mode` reuses `usage_key_for_input_in_mode`'s form-word disambiguation, and covers the advanced-SQL forms whose `usage_ids` are empty. Not keyed off `help_id` (it is `None` on the advanced-SQL nodes purely to dedup the `help` list; that parallel gap is issue **#36**). **Clause-concept hints** (`on delete` actions, constraint slots, `with pk`, cardinality) are a recorded **deferred extension** (`hint.concept.`, issue **#37**) — per-form is the right tier-3 granularity, with position-awareness owned by tier-2 + the live `Next:` line. Runtime `translate_error` classes resolve via stored `last_error_hint_key` (`hint` command / empty-F1). (The second route — pre-submit `diagnostic.*` read live from the walker on the F1 path — is **deferred**, issue **#38**: `Diagnostic` carries no class key.) Adds `AppCommand::Hint`, a `HINT` grammar node + REGISTRY entry, the `hint_ids` field, and `last_error_hint_key`; F1 is a read-only overlay (buffer + completion memo untouched). **Content is the bulk of the work** (the mechanism is ~a day): v1 scope = ~37 command forms + 9 runtime error classes (comprehensive for those, ~57 blocks), authored **exemplars-first** (voice approved in this ADR's `/runda` review, then mass-authored in batches), enforced by a **comprehensiveness coverage test**, with graceful fall-back to tier-2 if a key is ever missing. The **pre-submit-diagnostic route + ~33 `diagnostic.*` blocks were deferred** (issue **#38**) — `Diagnostic` carries no class key, so the route needs a broad change for marginal value (tier-2 already surfaces diagnostics; many duplicate runtime classes). Forks user-chosen: two-surface model; **F1** (vs `?` / a chord); no-arg; comprehensive-for-commands-and-errors scope; exemplars-first; diagnostics deferred. OOS: per-topic `hint ` (rejected — overlaps `help`); always-on tier-3 (rejected — keeps ambient terse); non-`en-US` locales + success-command teaching (deferred); clause-concept hints (issue #37); the diagnostic route (issue #38); the `help`-side advanced-SQL gap (issue #36) - [ADR-0054 — Release versioning policy + version surfaces (`--version` / `version`)](0054-release-versioning-and-version-surfaces.md) — **Accepted + implemented 2026-06-16** (plan: `docs/plans/20260616-public-availability.md`, step 1 on the road to public availability; no prior issue/`requirements.md` item — an untracked gap). Fixes the **tag↔crate-version decoupling**: `Cargo.toml` built `0.1.0` while `release.yaml` named assets from the git tag, so a binary could report a version different from the asset it shipped in. **Decision:** `Cargo.toml` `version` is the **single source of truth** (read via `env!("CARGO_PKG_VERSION")`, no tag-injection); two surfaces report it through one `cli::version_text()` → catalog `cli.version_line` — a **`--version` / `-V`** CLI flag (mirrors `--help`, prints+exits in `main.rs`) and an in-app **`version`** command (REGISTRY node `app::VERSION`, `AppCommand::Version`, emits via `note_system`); and a **release-CI version guard** (`release.yaml` `test` job reads the `[package]` version from `Cargo.toml` and **fails the release** unless the `v*` tag equals `v`; the guard's parse was later switched from `cargo metadata | node` to a `grep` on Cargo.toml after the former broke on the flake devShell's stdout banner). Release ritual: bump `Cargo.toml` → commit → tag → push. New keys `cli.version_line` + `help.app.version` + `parse.usage.version` + `hint.cmd.version.{what,example}` (the new REGISTRY command pulls in the comprehensiveness coverage gate). Rejected: tag-as-source (makes Cargo.toml lie). Deferred: git-hash/build-date enrichment (behind the same `version_text()` seam); UI placement beyond the command. Tested test-first: CLI parse (`--version`/`-V`/default-off), `version_text()` carries `CARGO_PKG_VERSION`, the in-app command parses + emits. Also corrected a stale `release.yaml` header comment ("macOS is deferred" → built by the dispatched `release-macos.yaml`). - [ADR-0055 — `curl | sh` install script (`scripts/install.sh`)](0055-curl-sh-install-script.md) — **Accepted + implemented 2026-06-17** (plan: `docs/plans/20260616-public-availability.md`, step 2; tracked by plan + ADR, no Gitea issue — user decision). A one-line installer (`curl -fsSL /scripts/install.sh | sh`) so beginners don't hand-pick an asset + `chmod +x`. **POSIX `sh`** (shellcheck-clean), detects `uname` OS/arch → target triple (**Linux → the fully-static `*-musl`** build, macOS → `*-apple-darwin`; `amd64`/`arm64` aliased; **Windows rejected** → Scoop/winget/releases page), resolves the version from the **`releases/latest`** API (or `RDBMS_VERSION` to pin), downloads the asset **and its `.sha256` and verifies it** (mismatch aborts), installs to `~/.local/bin` (`RDBMS_INSTALL_DIR` override) with a PATH hint. Testing seams: `RDBMS_OS`/`RDBMS_ARCH` + `--print-target`. macOS note: `curl` downloads aren't Gatekeeper-quarantined so the ad-hoc binary runs as-is (Developer-ID + notarization is the postponed signing task). **Verified end-to-end against the live public `v0.1.0`** (all platform mappings, pinned + latest, checksum incl. tamper-rejection, install + run). Rejected: website-domain hosting (extra moving part; Gitea raw is simplest); deferred: uploading the script as a release asset, and a **shellcheck CI gate** (shellcheck isn't in the flake — touches ADR-ci-002). **Amendment 1 (2026-06-17):** added a Windows **`scripts/install.ps1`** (`irm | iex`; maps host CPU → our `*-windows-gnu`/`-gnullvm` `.exe`, SHA-256-verifies, installs to `%LOCALAPPDATA%\Programs\…` + user PATH) — user chose both a one-liner *and* Scoop/winget; **written but untested from this env** (no PowerShell — validate on Windows). -- [ADR-0056 — crates.io publish-readiness + `cargo binstall` metadata (D3)](0056-crates-io-and-cargo-binstall.md) — **Prepared 2026-06-17** (plan step 3a; tracked by plan + ADR). Makes the crate **ready to publish** to crates.io (user decision) and adds `cargo-binstall` metadata; the actual `cargo publish` is a **gated, irreversible maintainer step**. Manifest: drops `publish = false`; adds `homepage` (relplay.org), `keywords`, `categories`, and an `exclude` (`/website`,`/docs`,`/.gitea`,`/.codegraph`) trimming the crate from 585 files/8.3 MiB → **353/913 KiB compressed** (code-only). Authors **`README.md`** (engine-neutral, simple/advanced-mode wording; install via curl|sh/binstall/source/prebuilt) and **`LICENSE-MIT`** (© Lazy Evaluation Ltd — *confirm holder*); the canonical **`LICENSE-APACHE`** is deferred to the maintainer (don't ship retyped legal text) — the SPDX `license` field already satisfies crates.io. **binstall** (syntax verified vs cargo-binstall SUPPORT.md): `pkg-fmt = "bin"` (bare binaries), `pkg-url` spelled `v{ version }` (the placeholder omits the `v`), plus per-target **`overrides`** mapping the common host triples to the assets we ship — `*-linux-gnu` → the static `*-linux-musl` build, `*-pc-windows-msvc` → `*-gnu`/`-gnullvm` `.exe` (macOS matches directly; the docs promise no automatic fallback). **Ordering:** publish at a **new tagged version whose release exists**, after the release — **not `0.1.0`** (diverges from the already-released 0.1.0 binaries that predate `--version`). Verified: `cargo publish --dry-run` packages + verify-builds; `cargo metadata` confirms the binstall block + 4 overrides. **Unverified:** a real `cargo binstall` run (not a dep; nothing on crates.io yet) — validate at first publish. Rejected: cargo-dist (GitHub-centric). Maintainer follow-ups: confirm © holder, add canonical `LICENSE-APACHE`, real binstall validation. **Amendment 1 (2026-06-18):** `0.2.0` **published live** (crates.io; `cargo install` + `cargo binstall` verified — the unverified-overrides caveat is resolved), via a new **manual `workflow_dispatch`** workflow `.gitea/workflows/publish.yaml` (mirrors `release-macos.yaml`; `tag` input; `cargo publish` with a crate-scoped `CARGO_REGISTRY_TOKEN` secret). Publish stays **manual** by decision — irreversible (keeps the token off every tag push), the split release (tag Linux/Windows + dispatched macOS) makes a human the "all assets up" gate, and crates.io has no Gitea-Actions trusted-publishing path. Each registry is its **own idempotent job** (crates.io job no-ops if the version exists) so Scoop/Homebrew/winget can be added as sibling jobs without interfering. **Amendment 2 (2026-06-19):** **Scoop + Homebrew wired** (D3 §3b/§3c) as sibling `publish.yaml` jobs (`scoop-bucket`, `homebrew-tap`) that render manifests from the release `.sha256` sidecars and push to **org-level, multi-package** repos `lazyeval/scoop-bucket` + `lazyeval/homebrew-tap`. Credential: a scoped bot user **`lazyeval-ci`** (Gitea PATs scope by permission-category, not per-repo, so an `oli` token would over-reach to the main repo) on a `lazyeval` org team with Write to the package repos only; its PAT is the `LAZYEVAL_PKG_TOKEN` secret on `oli/rdbms-playground`. Render scripts (`scripts/render-{scoop-manifest,homebrew-formula}.sh`) are **dependency-free bash** (CI image `node:22-slim` has no jq/ruby), tested by `scripts/test-package-renders.sh`. Scoop: `#/`-rename fragment + `checkver`, no `autoupdate`. Homebrew: `on_macos`/`on_linux`×arch bare-binary formula, no Windows. **Unverified:** real `scoop`/`brew install`, the `HEAD:main` branch assumption, macOS Gatekeeper-via-brew (ad-hoc sign). **Remaining D3:** winget. **Amendment 3 (2026-06-19):** **Scoop + Homebrew validated end-to-end** on real Windows + macOS (install + run). Found + fixed a **presigned-URL/HEAD gotcha**: Gitea (≥1.25) 303-redirects release downloads to a **method-bound SigV4 presigned S3 URL**; Homebrew resolves via HEAD, captures the HEAD-signed URL, then GETs it → 403 (GET-only tools unaffected). Fix is a **server-side Caddy rule** (lives in the Gitea edge config, NOT this repo — record it: lost on a rebuild → brew 403s again) answering HEAD on `…/releases/download/…` directly (200, no redirect) so brew's download GET re-runs the redirect fresh. ad-hoc mac signature **runs** via brew; Developer-ID + notarization still parked on the Apple org conversion. **Remaining D3:** winget only. +- [ADR-0056 — crates.io publish-readiness + `cargo binstall` metadata (D3)](0056-crates-io-and-cargo-binstall.md) — **Prepared 2026-06-17** (plan step 3a; tracked by plan + ADR). Makes the crate **ready to publish** to crates.io (user decision) and adds `cargo-binstall` metadata; the actual `cargo publish` is a **gated, irreversible maintainer step**. Manifest: drops `publish = false`; adds `homepage` (relplay.org), `keywords`, `categories`, and an `exclude` (`/website`,`/docs`,`/.gitea`,`/.codegraph`) trimming the crate from 585 files/8.3 MiB → **353/913 KiB compressed** (code-only). Authors **`README.md`** (engine-neutral, simple/advanced-mode wording; install via curl|sh/binstall/source/prebuilt) and **`LICENSE-MIT`** (© Lazy Evaluation Ltd — *confirm holder*); the canonical **`LICENSE-APACHE`** is deferred to the maintainer (don't ship retyped legal text) — the SPDX `license` field already satisfies crates.io. **binstall** (syntax verified vs cargo-binstall SUPPORT.md): `pkg-fmt = "bin"` (bare binaries), `pkg-url` spelled `v{ version }` (the placeholder omits the `v`), plus per-target **`overrides`** mapping the common host triples to the assets we ship — `*-linux-gnu` → the static `*-linux-musl` build, `*-pc-windows-msvc` → `*-gnu`/`-gnullvm` `.exe` (macOS matches directly; the docs promise no automatic fallback). **Ordering:** publish at a **new tagged version whose release exists**, after the release — **not `0.1.0`** (diverges from the already-released 0.1.0 binaries that predate `--version`). Verified: `cargo publish --dry-run` packages + verify-builds; `cargo metadata` confirms the binstall block + 4 overrides. **Unverified:** a real `cargo binstall` run (not a dep; nothing on crates.io yet) — validate at first publish. Rejected: cargo-dist (GitHub-centric). Maintainer follow-ups: confirm © holder, add canonical `LICENSE-APACHE`, real binstall validation. **Amendment 1 (2026-06-18):** `0.2.0` **published live** (crates.io; `cargo install` + `cargo binstall` verified — the unverified-overrides caveat is resolved), via a new **manual `workflow_dispatch`** workflow `.gitea/workflows/publish.yaml` (mirrors `release-macos.yaml`; `tag` input; `cargo publish` with a crate-scoped `CARGO_REGISTRY_TOKEN` secret). Publish stays **manual** by decision — irreversible (keeps the token off every tag push), the split release (tag Linux/Windows + dispatched macOS) makes a human the "all assets up" gate, and crates.io has no Gitea-Actions trusted-publishing path. Each registry is its **own idempotent job** (crates.io job no-ops if the version exists) so Scoop/Homebrew/winget can be added as sibling jobs without interfering. **Amendment 2 (2026-06-19):** **Scoop + Homebrew wired** (D3 §3b/§3c) as sibling `publish.yaml` jobs (`scoop-bucket`, `homebrew-tap`) that render manifests from the release `.sha256` sidecars and push to **org-level, multi-package** repos `lazyeval/scoop-bucket` + `lazyeval/homebrew-tap`. Credential: a scoped bot user **`lazyeval-ci`** (Gitea PATs scope by permission-category, not per-repo, so an `oli` token would over-reach to the main repo) on a `lazyeval` org team with Write to the package repos only; its PAT is the `LAZYEVAL_PKG_TOKEN` secret on `oli/rdbms-playground`. Render scripts (`scripts/render-{scoop-manifest,homebrew-formula}.sh`) are **dependency-free bash** (CI image `node:22-slim` has no jq/ruby), tested by `scripts/test-package-renders.sh`. Scoop: `#/`-rename fragment + `checkver`, no `autoupdate`. Homebrew: `on_macos`/`on_linux`×arch bare-binary formula, no Windows. **Unverified:** real `scoop`/`brew install`, the `HEAD:main` branch assumption, macOS Gatekeeper-via-brew (ad-hoc sign). **Remaining D3:** winget. **Amendment 3 (2026-06-19):** **Scoop + Homebrew validated end-to-end** on real Windows + macOS (install + run). Found + fixed a **presigned-URL/HEAD gotcha**: Gitea (≥1.25) 303-redirects release downloads to a **method-bound SigV4 presigned S3 URL**; Homebrew resolves via HEAD, captures the HEAD-signed URL, then GETs it → 403 (GET-only tools unaffected). Fix is a **server-side Caddy rule** (lives in the Gitea edge config, NOT this repo — record it: lost on a rebuild → brew 403s again) answering HEAD on `…/releases/download/…` directly (200, no redirect) so brew's download GET re-runs the redirect fresh. ad-hoc mac signature **runs** via brew; Developer-ID + notarization still parked on the Apple org conversion. **Remaining D3:** winget only. **Amendment 4 (2026-06-20):** **winget wired** (D3 §3d) — the 4th `publish.yaml` sibling job opens a PR to the central, human-gated `microsoft/winget-pkgs` via **komac** (pinned 2.16.0; `LazyEvaluation.RdbmsPlayground`, `portable`, x64+arm64). Async + Microsoft-validated, unlike the bucket/tap. Auth: a **classic `public_repo` PAT** (fine-grained can't open the cross-fork PR, komac #310) on a **dedicated GitHub bot** → `WINGET_GITHUB_TOKEN`, job-scoped, passed via curl config file (not argv). Idempotent via **two guards** (already-merged version + already-open PR) so re-dispatch is safe. Unsigned is fine to submit (portable; only MSIX needs signing) — may earn an AV/SmartScreen manual-review label first time. **One-time `komac new` bootstrap is manual** (interactive); CI `komac update` handles releases after. Completes the D3 package-manager set. diff --git a/docs/requirements.md b/docs/requirements.md index 5beb6ee..82b0b12 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -96,9 +96,13 @@ since ADR-0027.) a **server-side Caddy rule** answering HEAD on release-download paths directly (Gitea's presigned-S3 redirect is method-bound and brew reuses a HEAD-signed URL for its GET) — see ADR-0056 Amendment 3; - that rule lives in the Gitea edge config, not this repo. **Remaining: - winget** (komac on Linux CI, or a manual PR). Asset naming - `rdbms-playground--` is binstall-friendly.)* + that rule lives in the Gitea edge config, not this repo. **winget + wired** (ADR-0056 Amendment 4) — a `publish.yaml` `winget` job submits + a PR to `microsoft/winget-pkgs` via komac (`LazyEvaluation.RdbmsPlayground`, + portable, x64+arm64), guarded against duplicate PRs; pending the + **one-time manual `komac new` bootstrap** + Microsoft review before + it goes live. Asset naming `rdbms-playground--` is + binstall-friendly.)* ## TUI shell From 56e3456cfcc0e74c1535482a9a94d13def2d957c Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Sun, 21 Jun 2026 22:05:00 +0000 Subject: [PATCH 20/20] =?UTF-8?q?docs:=20handoff=2075=20=E2=80=94=20D3=20p?= =?UTF-8?q?ackage=20managers=20complete=20(Scoop/Homebrew/winget)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers the session that finished the D3 rollout: install.ps1 Windows 5.1 fixes, Scoop + Homebrew wiring and validation, the Homebrew-403 presigned-URL saga fixed via a server-side Caddy HEAD rule, and the winget job + komac bootstrap (PR #391335). Records the infra created off-repo (Caddy rule, the lazyeval Gitea/GitHub bots + tokens), the gotchas, and the parked signing tracks. --- docs/handoff/20260621-handoff-75.md | 145 ++++++++++++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 docs/handoff/20260621-handoff-75.md diff --git a/docs/handoff/20260621-handoff-75.md b/docs/handoff/20260621-handoff-75.md new file mode 100644 index 0000000..c91d245 --- /dev/null +++ b/docs/handoff/20260621-handoff-75.md @@ -0,0 +1,145 @@ +# Session handoff — 2026-06-21 (75) + +Continues from handoff-74 (v0.2.0 live on crates.io). This session **finished +the D3 package-manager rollout** — Scoop, Homebrew, and winget — plus the +Windows `install.ps1` fixes, and included a deep **presigned-URL / Homebrew** +debugging saga that's fixed by a server-side Caddy rule. Five commits; one +new ADR amendment arc (ADR-0056 Amendments 2–4). + +## §1. State + +**Branch `main`.** **No crate (Rust) code changed this session** — only +`.gitea/workflows/`, `scripts/`, and `docs/`. So the `cargo test` baseline +from handoff-74 (**2509 pass / 0 fail / 1 ignored**, clippy + `fmt --check` +clean) stands unchanged by construction; not re-run (nothing in the build +graph moved). New coverage this session is **`scripts/test-package-renders.sh`** +(shellcheck-clean, green), now gated on every push by a new **`ci.yaml` +`manifests` job** (bash + node; ruby-absent in CI degrades gracefully). + +**Commits this session** (`cabc813`→`0208c67`; the workflow-bearing ones were +pushed, since the live `publish` dispatches + raw-URL installs the user tested +needed them on `main`): +- `42b40bc` install.ps1 → Windows PowerShell 5.1 compat +- `c0531aa` install.ps1 → immediate-use PATH + honest messaging +- `6d54c1e` Scoop + Homebrew jobs (D3 §3b/§3c) +- `6bb2288` Scoop/Homebrew validated + Caddy fix recorded (Amendment 3) +- `0208c67` winget job via komac (D3 §3d, Amendment 4) + +**External / infra state created this session (NOT all in the repo):** +- **Caddy Tier-A HEAD rule on the Gitea server** — *load-bearing for Homebrew*, + lives in the Gitea edge config, **not** this repo (see §4). If lost on a + server rebuild, brew 403s again. +- **Gitea:** `lazyeval` org with repos **`scoop-bucket`** + **`homebrew-tap`**; + **`lazyeval-ci`** Gitea bot user (org team, Write to those repos); + **`LAZYEVAL_PKG_TOKEN`** secret on `oli/rdbms-playground`. +- **GitHub:** **`lazyeval-ci`** bot account; classic `public_repo` PAT → + **`WINGET_GITHUB_TOKEN`** secret on `oli/rdbms-playground`; fork + **`lazyeval-ci/winget-pkgs`**. +- **winget bootstrap PR [#391335](https://github.com/microsoft/winget-pkgs/pull/391335)** + submitted, in Microsoft review. +- **komac 2.16.0** installed locally at `~/.local/bin/komac`. + +## §2. D3 — the package-manager set (the session's throughline) + +| Channel | State | +| --- | --- | +| crates.io / `cargo binstall` | **live** (handoff-74) | +| `install.sh` / `install.ps1` | **live** — PS1 validated on ARM Windows 11 (5.1 + 7.6) | +| Scoop (`lazyeval/scoop-bucket`) | **live + validated** (install + run) | +| Homebrew (`lazyeval/homebrew-tap`) | **live + validated** — needs the Caddy rule (§4) | +| winget (`LazyEvaluation.RdbmsPlayground`) | **wired**; PR #391335 awaiting Microsoft merge | + +How Scoop/Homebrew/winget are wired: sibling jobs in +`.gitea/workflows/publish.yaml` (manual `workflow_dispatch`, `tag` input), +each idempotent + independent. Scoop/Homebrew render **dependency-free bash** +manifests (`scripts/render-{scoop-manifest,homebrew-formula}.sh`) from the +release `.sha256` sidecars and push to the org repos via `LAZYEVAL_PKG_TOKEN`. +winget runs `komac update --submit` to PR `microsoft/winget-pkgs`, guarded +against duplicate PRs. ADR-0056 **Amendments 2–4** carry the full design. + +## §3. The Homebrew 403 saga + Caddy fix (don't lose this) + +Homebrew `brew install` 403'd while every other tool worked. Root cause, +reproduced: Gitea (≥1.25; here 1.26.2) **303-redirects release downloads to a +method-bound AWS-SigV4 presigned S3 URL** (OVH), signed for the *incoming* +request's HTTP verb. Homebrew **resolves with a HEAD, captures the returned +HEAD-signed URL, then runs the download GET against it** → GET-on-a-HEAD-signed +URL → 403. (`GET on HEAD-resolved = 403`, `HEAD on HEAD-resolved = 200`, +`GET on GET-resolved = 200`.) GET-only tools (install.sh/ps1, binstall, curl) +are unaffected. This is a **Homebrew defect** (reusing an ephemeral +method-scoped credential as a durable locator); Gitea's per-request signing is +correct, and SigV4 *cannot* sign one URL for two verbs. `SERVE_DIRECT=false` +would fix it but was **declined** (don't reshape storage for one client). + +**Fix (deployed, "Tier A"):** a Caddy rule answering **HEAD on +`…/releases/download/…` directly** (200, no redirect, no body) so brew's +resolve records the *original* URL and its download GET redirects fresh → +GET-signed → 200. GET untouched; a HEAD carries no payload so nothing can +break (a HEAD-probing download manager at most loses a progress-bar size). +Reference Caddyfile is in **ADR-0056 Amendment 3**. + +## §4. Immediate next steps + +1. **winget PR #391335:** watch validation — the one likely speed bump is an + **AV/SmartScreen manual-review label** (unsigned binary); usually clears. + Once a moderator merges, winget is live. +2. **Post-merge check (Windows):** `winget install LazyEvaluation.RdbmsPlayground`, + confirm the command is **`rdbms-playground`** (it is — a direct single-exe + portable takes its PATH alias from `Commands[0]`, which komac set; see §5). +3. After merge, future releases are hands-off: the CI `winget` job's + `komac update` carries each version (alias inherited from the merged + manifest). The release ritual is unchanged from ADR-0054, with the + `publish` dispatch now also doing Scoop/Homebrew/winget. + +## §5. Gotchas learned (don't relearn the hard way) + +- **The Caddy HEAD rule is server-side, not in this repo.** Homebrew (and any + HEAD-then-GET client) depends on it. Record/back it up. +- **GitHub tokens:** komac needs a **classic `public_repo`** PAT — **fine-grained + tokens can't open the cross-fork PR** (komac #310: the PR is created on the + *target* repo you don't administer, which the fine-grained model can't + express). A classic token can't be repo-scoped, so it lives on a **dedicated + bot** (`lazyeval-ci`) to bound the blast radius. +- **komac fork must pre-exist:** `komac new` auto-fork *races* on winget-pkgs + (huge repo) → *"Could not resolve to a Repository 'lazyeval-ci/winget-pkgs'"*. + Fork manually first (`gh repo fork microsoft/winget-pkgs --clone=false`), + wait, then re-run. Pre-fill `komac new` flags to skip the long interactive + prompt parade. +- **Direct portable alias = `Commands[0]`, NOT `PortableCommandAlias`.** The + latter is **archive/nested-portable-only**; adding it to a bare-exe portable + is wrong (komac correctly omits it). The "Commands" prompt → `rdbms-playground` + is the on-PATH command. Verified vs winget-cli logic + the 1.6.0 schema. +- **install.ps1 / Windows PowerShell 5.1** (the in-box shell — PS7 is opt-in): + arch via `PROCESSOR_ARCHITECTURE` env (not `RuntimeInformation::OSArchitecture`, + which is absent under 5.1's .NET-Framework facade + StrictMode); force TLS 1.2; + `-UseBasicParsing`. Also update `$env:Path` in-session (the persisted User PATH + only reaches *new* processes; "restart your shell" was wrong — sign-out/in or + the in-session update). +- **Gitea release downloads go through OVH S3 presigned URLs** — method-bound, + 300 s expiry. Never assume HEAD-resolve-then-GET works against them. + +## §6. Parked / deferred (user decisions) + +- **macOS Developer-ID signing + notarization** — pending the Apple account → + Organization conversion. Ad-hoc signing covers all package-manager paths + (brew-installed binary *runs* on Apple Silicon); Developer-ID only matters for + browser-download Gatekeeper trust. +- **Windows code signing (Trusted Signing)** — not required to *ship* winget + (portable; only MSIX needs signing). Unsigned can earn an AV/SmartScreen + manual-review label + a user-run warning. **Lazy Evaluation Ltd (since 2012) + clears Trusted Signing's 3-year-org bar** when the user wants to remove the + warning (individual onboarding is currently paused; org path is open). +- **Release notes / CHANGELOG** — raised this session, not done. If wanted: + a `CHANGELOG.md` and/or populated Gitea release bodies, then the CI `winget` + job can pass komac `--release-notes-url …/releases/tag/$TAG`. + +## §7. How to take over + +1. Read handoffs 73 → 74 → 75, `CLAUDE.md`, `docs/requirements.md` (D1/D3), + and **ADR-0056 (esp. Amendments 1–4)** + the GA plan + `docs/plans/20260616-public-availability.md`. +2. Workflow unchanged: phased, test-first, `/runda` + DA before commits, ADR + amendment + README index-upkeep for decided-area changes, confirm commit + messages, never push. +3. If `cargo test` is needed, the baseline is **2509 / 1 ignored** (handoff-74). +4. Consider a `cargo sweep` at this milestone (`target/` grows).