Commit Graph
100 Commits
Author SHA1 Message Date
claude@clouddev1 5fa3460ff6 add handoff-14: handoff-12 §2 backlog cleared (8 items) 2026-05-15 22:48:09 +00:00
claude@clouddev1 03dd9003df Help: consume CommandNode.help_id — REGISTRY-driven in-app help
Every CommandNode declared a help_id that nothing read; the in-app
`help` body was a single hand-kept catalog block that drifted from
the command set (handoff-12 §2.1).

note_help now iterates the command REGISTRY and translates each
CommandNode's help_id (`help.<id>`), framed by help.intro /
help.dsl_section / help.types_reference. A newly-registered command
appears in `help` automatically — no edit to note_help or a hand-kept
list. Added 20 per-command help entries plus the 3 framing entries;
removed help.in_app_body.

Per-command entries use block scalars: a libyml 0.0.5 scanner bug
panics on long internal space runs in double-quoted scalars, and the
entries are space-aligned.
2026-05-15 22:45:18 +00:00
claude@clouddev1 f46606b12e Runtime: schema-aware replay parsing
run_replay parsed each line with the schemaless parse_command, so
Phase D typed-slot rejections (wrong-count value lists, wrong-type
column values) fired only at bind time during replay — inconsistent
with the interactive path (handoff-12 §2.1).

run_replay now re-snapshots the schema per line (the schema mutates
as replayed create-table / add-column commands run) and parses with
parse_command_with_schema. Extracted build_schema_cache, shared with
the interactive refresh_schema_cache.

Added a replay integration test asserting a typed-slot violation is
caught at parse time (through the replay.error_parse wrapper).
2026-05-15 22:31:19 +00:00
claude@clouddev1 90e3f5dbfb Insert grammar: Form C type-awareness via lookahead (ADR-0024 §Phase D)
Form C (`insert into T (vals)`) shared the `(` opener with Form A,
so its paren was an untyped Repeated(Choice(literal, ident)) — values
weren't type- or count-checked at parse time (handoff-12 §2.2).

New Node::Lookahead variant: a factory that peeks the source. The
insert first-paren factory inspects the first token — a value literal
routes the contents through the typed column_value_list (Form B
dispatch contract: per-non-auto-column typed slots); an identifier or
empty paren routes to a Form A column-name list. So Form C now gets
the same per-column typed slots, hints, and parse-time type/count
checking Form B has.

The explicit-Choice-branch split is impossible here (committed-choice
semantics commit after `(` matches); lookahead is the only route, and
DynamicSubgrammar factories couldn't see the source. Node::Lookahead
is not memoized — its output depends on source — but it returns only
a small node (a Repeated, or a thin DynamicSubgrammar wrapper that
delegates to the memoized column_value_list).

`insert into T (` now cleanly shows Form A column candidates instead
of mixed Form-A/C suggestions. Form C matrix tests updated for the
type-aware behaviour.
2026-05-15 22:27:53 +00:00
claude@clouddev1 9bbb96e735 Walker: memoize DynamicSubgrammar resolution to bound the Box::leak
Node::DynamicSubgrammar factories build a Node from the WalkContext and
must Box::leak it (the Node enum's combinator children are &'static).
Leaking per walk grew unbounded under per-keystroke completion
(handoff-12 §2.1).

resolve_dynamic now memoizes on the schema state a factory reads
(table columns, current column, user-listed columns) keyed by factory
fn-pointer. Each distinct value-list shape leaks exactly once — total
leak bounded by distinct (schema × form) combinations, not keystroke
count. TableColumn gains Hash for the cache key.

The handoff's original arena sketch needed a lifetime-generic Node
(major refactor); memoization gets the same bound without it.
2026-05-15 22:06:33 +00:00
claude@clouddev1 911a537a83 Walker: node-attached HintMode via Node::Hinted (ADR-0024 §HintMode-per-node)
Replaces the hint resolver's signature-matching (does the expected set
contain all five literal forms? an Ident{NewName}?) with a grammar-
declared annotation. New Node::Hinted { mode, inner } wrapper; the
walker records the mode in WalkContext::pending_hint_mode on entry and
clears it on any successful match (cursor moved past the slot — this
also undoes the leak where a failed Hinted branch of a Choice would
otherwise strand a stale mode). The resolver reads pending_hint_mode
directly.

Value-literal fallback slots carry ProseOnly; NewName ident slots carry
ForceProse. hint_mode_at_input_inner now delegates to
hint_resolution_at_input — one resolution path, no duplicated logic.
No behaviour change; the typing-surface matrix guards it.
2026-05-15 21:58:22 +00:00
claude@clouddev1 f1ff5970bf Hint: pedagogical Form-A pointer at Form B's first value slot
Handoff-12 §2.2: Form B `insert into T values (…)` silently skips
auto-generated columns from the value list, so a user who wants to
set a serial/shortid column explicitly could only discover Form A by
reading help. Now the hint at the first Form B value slot appends a
note naming the skipped column(s) and pointing at the explicit-column
form.

hint_resolution_at_input derives the skipped columns from the
post-walk WalkContext (Form B = no user_listed_columns + table has
serial/shortid columns) and reports them on HintResolution; the note
fires only at the first slot so it doesn't repeat at every comma.
ambient_hint composes it onto the per-column prose.
2026-05-15 21:30:03 +00:00
claude@clouddev1 bcc5ad2f20 Matrix: pin natural candidate ordering
8 tests covering completion-candidate order: connective keywords in
reading order (`to`/`from`/`in` before `table`), and command-part
keywords before schema identifiers. The ordering already held via
declaration-order preservation + keywords-first sectioning in
candidates_at_cursor; nothing pinned it until now, so a future
grammar or sort change could silently break the hint panel's
left-to-right reading.
2026-05-15 21:29:54 +00:00
claude@clouddev1 50b78253d8 Remove dead parse.token.* catalog entries
The 5 structural-class entries (identifier/number/string_literal/flag/
end_of_input) and 3 lex-error entries (bad_flag/unknown_char/
unterminated_string) were unreachable: ADR-0024 Phase F made the
walker render keyword wording verbatim and the lex errors never
surface through today's walker. Handoff-12 §2.1 kept them as a
conservative call; removed now per user request. Re-adding is cheap
if a future need arises.
2026-05-15 21:29:36 +00:00
claude@clouddev1 42cf851f7e add handoff-13: typing-surface matrix complete + 5 matrix-found bug fixes 2026-05-15 20:53:25 +00:00
claude@clouddev1 c7ecc64757 Matrix: create table, DDL, and app-command coverage
55 tests covering create table, drop column, drop relationship
(endpoints + by-name), add relationship, rename/change column, and
all app-lifecycle commands. The drop-column and relationship tests
drove the §2.2 writes_table fix in the previous commit.

Documents one UX wrinkle as a flagged finding: partial entry words
(`qu`) classify as DefiniteErrorAt — same as unknown commands —
because the walker only engages on a complete entry word.

859 baseline -> 989 passing; 1 ignored (pre-existing doc-test).
2026-05-15 20:50:56 +00:00
claude@clouddev1 216e7ba61b DDL grammar: writes_table on table-name slots for column narrowing
Handoff-12 §2.2: the DDL TABLE_NAME_EXISTING slot and the
relationship-endpoint table idents didn't set writes_table, so
column-name slots downstream (drop/rename/change column; relationship
qualified columns) couldn't narrow to the active table — candidates
leaked from every table. Set writes_table: true on TABLE_NAME_EXISTING
and on DR_PARENT/DR_CHILD/AR_PARENT/AR_CHILD table idents. The
deliberately-documenting completion test now asserts per-table
narrowing.
2026-05-15 20:50:56 +00:00
claude@clouddev1 37db2f5dd2 Matrix: insert Form C + update + delete coverage
34 new tests covering:
- Form C bare-value-list (happy path + Form-A-recovery + type-unaware grammar limitation per handoff §2.2)
- update with WHERE (column-narrowing invariant per handoff §1 bug E1; typed-slot prose for assignments and where filters)
- update --all-rows (filter-clause requirement per ADR-0014)
- delete with WHERE (column-narrowing; typed-slot prose for where filters)
- delete --all-rows

859 baseline -> 931 passing. No bugs surfaced — the data-mutation
command family was already well-shaped post-Phase-D.
2026-05-15 20:34:01 +00:00
claude@clouddev1 a9a04cff97 Matrix: insert Form B coverage
12 tests across schema_serial_pk / text_pk / multi_table / every_type.
Pins (a) Form B skips auto-gen columns from the slot list (regression
for handoff-12 §B fix); (b) wrong-count value lists are now flagged
at typing time, not only at submit (the previous commit's fix); and
(c) per-type slot prose advances correctly through every Type variant.
2026-05-15 20:31:01 +00:00
claude@clouddev1 fffb44ff4f input_render: schema-aware classify_input for wrong-count value lists
classify_input was schemaless; wrong-count Form B value lists
(`insert into Customers values ('Alice')` against a 3-column table)
showed as Valid until submit. Add classify_input_with_schema that
threads the SchemaCache through parse_command_with_schema and wire
render_input_runs to use it. Schemaless classify_input is kept public
for handoff-11/12 regression tests that exercise schema-independent
positions.
2026-05-15 20:31:01 +00:00
claude@clouddev1 24e641bc21 Matrix: typing-surface infrastructure + insert Form A coverage
Per docs/handoff/20260515-handoff-12.md §1. Systematic per-position
coverage of (state, hint, completion, parse_result) across canonical
schema shapes; submodule per command family. Insert Form A covers 23
cursor positions across serial-PK, text-PK, multi-table, and
every-Type schemas. Both bugs fixed in the previous commit were
surfaced by these tests.

Shared helpers under tests/typing_surface/mod.rs: 5 canonical schema
shapes, assess() helper, property-assertion shortcuts, and a snap!
macro that wraps insta with a stable per-cell suffix.

859 -> 885 tests passing; 1 ignored (pre-existing doc-test).
2026-05-15 20:06:58 +00:00
claude@clouddev1 0b15ce0306 Walker + parser: surface mid-typing after separators and Form C/A ambiguity
The typing-surface matrix exposed two bugs the existing 859-test suite
missed:

walk_repeated: when the separator consumed but the inner item failed
at EOF, the old path rolled the separator back and reported a definite
error at the rollback position (`insert into T (a, ` flashed red on
the `,` after each comma). Now propagates Incomplete with the inner's
expected set so the input renderer treats it as mid-typing.

build_insert Form C path: `insert into T (col)` walked to a complete
match but produced `values: []` because Form C's value collector drops
ident-shaped items. The user almost certainly meant Form A and just
hasn't typed `values (...)` yet. Reject with a ValidationError naming
the Form-A continuation; classify_input now reports IncompleteAtEof.

completion_probe / expected_at_input: ValidationFailed used to return
an empty expected set, leaving Tab with nothing to offer at the new
Form-A flag point. Now surface result.tail_expected (skipped-Optional
expectations captured before validation fired) so `values` is still
offered as a candidate.
2026-05-15 20:06:52 +00:00
claude@clouddev1 3b1955c6bf add handoff-12: post-Phase-D UX fixes + test-coverage agenda for next session 2026-05-15 19:15:27 +00:00
claude@clouddev1 619a8bd707 Completion: narrow column candidates to the active table
Two related fixes:

1. \`update MyTable set \` was offering columns from every
   table in the project — completion fetched
   \`cache.for_source(IdentSource::Columns)\` which returns the
   flat \`cache.columns\` (union of every table's columns).
   The walker's WalkContext had \`current_table_columns\`
   populated (because the update-table-name slot is
   \`writes_table: true\`) but the completion engine never
   consulted it.

2. \`insert into MyTable (\` was offering nothing — the
   value-literal suppression fired because the expected set at
   this position contains both Form A column-list candidates
   (\`Ident{Columns}\`) and Form C bare-value-list literals
   (null/true/false/NumberLit/StringLit). \`is_value_literal_signature\`
   matched and the engine returned \`None\` before the column
   candidates were considered.

The fix threads the walker's \`current_table_columns\` through
to the completion engine and narrows the suppression rule:

**Walker:**
- New \`walker::CompletionProbe { expected, current_table_columns }\`
  struct.
- New \`walker::completion_probe(source, schema) -> CompletionProbe\`
  runs one schema-aware walk and reports both the expected
  set (or tail_expected on Match) and the resolved table-column
  snapshot.

**Completion engine:**
- \`candidates_at_cursor_with\` calls \`completion_probe\` and
  reads \`current_table_columns\` for the \`Columns\` ident
  source. Schemaless or unknown-table falls back to the flat
  \`cache.columns\` (preserves pre-fix behavior).
- Value-literal suppression now gated on
  \`!has_schema_ident\` — if the expected set also offers a
  schema-listable Ident, the user has actionable candidates
  beyond the misleading null/true/false trio and we shouldn't
  hide them.

Tests:
- \`update_set_offers_only_current_table_columns\` confirms
  Customers' columns appear while Orders' columns don't.
- \`update_where_offers_only_current_table_columns\` covers
  the where path.
- \`insert_into_open_paren_offers_current_table_columns\` and
  \`insert_into_open_paren_does_not_offer_unrelated_columns\`
  cover the Form A column-list position.
- \`drop_column_from_offers_only_current_table_columns\`
  documents the DDL fallback (drop-column's table-name slot
  doesn't currently \`writes_table\` — falls back to the flat
  list).

For the user: \`update MyTable set \` now offers only
MyTable's columns. \`insert into MyTable (\` offers all of
MyTable's columns so Form A is fully discoverable.

Tests: 859 passing, 0 failing, 1 ignored. Clippy clean.
2026-05-15 19:07:46 +00:00
claude@clouddev1 5815918efb Hint: surface ( as a branching candidate; stop red-flagging in-progress Form A values
Two related fixes from a user-reported snag:

1. After typing \`insert into Orders \`, the hint suggested only
   \`values\` even though the user could also choose \`(\` to
   open Form A (the explicit-column-list variant). The walker
   reports both \`Expectation::Word("values")\` and
   \`Expectation::Punct('(')\` at that position, but
   \`candidates_at_cursor\` had a blanket "no punctuation as Tab
   candidate" policy.

   Loosened the policy to surface branching punct
   (specifically \`(\` opening a sub-shape). Closing punct
   (\`)\`), separators (\`,\`), and content-trailing punct (\`:\`,
   \`=\`, \`.\`) stay out — the user types those naturally and
   advertising them in the Tab menu is noise. New
   \`CandidateKind::Punct\` so the renderer colors it as punct
   rather than mis-classifying as a keyword.

2. While typing \`insert into Orders (id, CustId, Total) values
   (42, 89, 17.59\` (no closing paren yet), the word \`values\`
   was rendered in \`tok_error\` red. The walker's
   \`Optional(Seq[values, '(', list, ')'])\` was rolling back on
   the partial inner match — treating \`(id, CustId, Total)\` as
   Form C (bare value list) followed by trailing junk starting
   at \`values\`. The classify_input call thus returned
   \`DefiniteErrorAt(<values byte>)\` and the renderer overlaid.

   Tightened \`walk_optional\`: roll back only when the inner
   reports NoMatch (or Incomplete / Mismatch without consuming
   anything). Once the inner has committed to at least one
   terminal (e.g. matched the \`values\` keyword), propagate
   Incomplete / Mismatch up — the user is mid-typing the
   optional's content and rolling back would lose their
   intent.

   The pre-existing chumsky-or_not-style aggressive rollback
   covered cases like \`save Customers\` (Optional(\`as\`)
   inner is a single Word that returns NoMatch without
   consuming, so rollback still fires). Those keep working.

3. Side effect: with \`Optional\` no longer hiding the
   in-progress Form A from the leading slice, the walker on
   \`create table T with \` correctly reports the next-expected
   keyword as \`pk\` — so cursor at the end of the complete
   command \`create table T with pk\` would now re-offer \`pk\`
   as a Tab candidate against the partial \"pk\". Added a final
   filter: when the full input is a valid parse AND the
   partial prefix is non-empty, drop candidates that equal the
   partial exactly. Preserves schema narrowing
   (\`show data Cu\` → \`Customers\` is not an exact match).

Tests:
- New \`in_progress_form_a_values_list_classifies_as_incomplete\`
  asserts the input-state for the user's exact scenario.
- New \`open_paren_branching_punct_surfaces_after_insert_into_table\`
  and \`open_paren_candidate_is_classified_as_punct_kind\` cover
  the punct-as-candidate surface.
- Renamed and rewrote \`punctuation_expected_does_not_produce_candidates\`
  to \`non_branching_punctuation_is_not_surfaced_as_candidate\`
  to document the new finer-grained policy.
- Existing tests for \`save Tab → as\` and the schema-
  narrowing case continue to pass.

Tests: 854 passing, 0 failing, 1 ignored. Clippy clean.
2026-05-15 18:58:28 +00:00
claude@clouddev1 b3f1a20652 Phase D: insert value list mirrors do_insert's user_cols contract
Bug: hint at \`insert into Customers values (\` for a Customers
table with id:serial PK suggested typing an integer for \`id\`,
but the dispatch path (\`db::do_insert\`) deliberately doesn't
accept user-supplied values for auto-generated columns in
Form B. The grammar prompted for a value the dispatch would
refuse.

The fix aligns Phase D's \`column_value_list\` dynamic sub-grammar
with do_insert's three forms (ADR-0014 + ADR-0018 §3):

- **Form A** \`insert into <T> (col1, col2, …) values (…)\` —
  user explicitly lists columns. Slot list mirrors that
  selection; serial / shortid columns CAN appear if the user
  lists them.
- **Form B** \`insert into <T> values (…)\` — bare values. Slot
  list = non-auto-generated columns of the table in
  declaration order. Serial / shortid get auto-filled by the
  dispatch; the grammar doesn't prompt for them.
- **Form C** \`insert into <T> (v1, v2, …)\` — bare value list.
  Not affected by this change (column_value_list isn't on this
  path; Form C's literals route through the schemaless
  INSERT_PAREN_LIST).

Implementation:

\`WalkContext.user_listed_columns: Option<Vec<String>>\` — when
\`Some\`, signals Form A; \`None\` is Form B. Populated by walking
the first paren's column-list idents.

\`Node::Ident.writes_user_listed_column: bool\` — new field;
\`true\` on the INSERT_PAREN_ITEM's Ident child. When the
walker matches that ident in Form A, it appends the
schema-canonical column name (case-corrected against the
schema) to user_listed_columns.

\`column_value_list\` factory:
- If user_listed_columns is Some → resolve each name from the
  schema; one typed slot per listed column.
- Else → filter current_table_columns to non-auto-generated;
  one typed slot per remaining column.
- Empty result → fall back to the schemaless value-literal
  list (a serial-only table in Form B has nothing for the
  user to type).

Tests:
- New \`phase_d_insert_form_b_skips_serial_column\` confirms the
  bug: \`insert into Customers values (1, 'Alice')\` against a
  Customers with serial id rejects at parse time (Form B
  expects 1 value for Name, not 2).
- New \`phase_d_insert_form_a_accepts_serial_when_listed\`
  confirms \`insert into Customers (id, Name) values (1, 'Alice')\`
  works.
- New \`phase_d_insert_form_a_filters_to_user_listed_columns\`
  confirms partial Form A (\`(Name) values ('Alice')\`).
- Updated \`phase_d_insert_with_schema_accepts_typed_values_per_column\`
  to match the new Form B contract (2 user-typed values, not 3).
- Updated typed-hint test matrix split into form-B (8 types)
  and form-A (serial / shortid).
- New \`typed_hint_form_b_skips_serial_column_to_generic_or_text_neighbor\`
  pins the fallback behavior for a serial-only table.

For the user: \`insert into Customers values (\` for a Customers
with \`(id:serial, Name:text, Email:text)\` now hints
\`for \`Name\`: Type a quoted string …\` (skipping id entirely)
and accepts exactly 2 values. To set the serial explicitly,
use Form A: \`insert into Customers (id, Name, Email) values
(1, 'Alice', 'a@b.c')\`.

Tests: 851 passing, 0 failing, 1 ignored. Clippy clean.
2026-05-15 18:45:47 +00:00
claude@clouddev1 c485189da8 ADR-0024 Phase D: include column name in value-slot hint prose
User-facing improvement: typing into a value slot now surfaces
the column name in the hint. The hint at `insert into Customers
values (` (first column id:int) reads "for `id`: Type an
integer (e.g. 42, -7) or null" instead of the generic
"Type an integer …" prose. After `1, ` the panel updates to
the second column ("for `Name`: Type a quoted string …"). The
same applies to `update T set Email=` and `delete from T where
ts=` — the catalog wrapper threads the column name through.

Implementation:

**`Node::TypedValueSlot.column_name: Option<&'static str>`**
(new field, `src/dsl/grammar/mod.rs`). When `Some`, walker
writes `WalkContext::pending_value_column` on entry; clears
along with `pending_value_type` on inner success.

**Walker driver writes both names** (`src/dsl/walker/driver.rs`):
- `Node::TypedValueSlot` dispatch reads `column_name` and
  populates `pending_value_column`.
- `Ident { writes_column: true }` dispatch also writes
  `pending_value_column` (using the schema-canonical name when
  available, falling back to the user's spelling) so update
  set / where positions surface the column name.

**Shared sub-grammars** (`src/dsl/grammar/shared.rs`):
- New `slot_for_column(ty, name)` builds a `TypedValueSlot`
  with the embedded leaked column name. Used by
  `column_value_list`.
- New `slot_inner_for_type(ty)` returns just the Choice
  (without TypedValueSlot wrapper) for slot_for_column to
  rebuild.
- `column_value_list` factory now constructs per-column slots
  via `slot_for_column(col.user_type, &col.name)`. Each slot
  leaks its column name string with the same per-walk Box::leak
  pattern the rest of dynamic dispatch uses.

**`WalkContext::pending_value_column: Option<String>`** (new
field, `src/dsl/walker/context.rs`). Pairs with
`pending_value_type` to give the hint resolver both pieces.

**Single-walk hint resolver** (`src/dsl/walker/mod.rs`):
- New `HintResolution { mode: HintMode, column: Option<String> }`
  struct.
- New `hint_resolution_at_input(source, schema) -> Option<
  HintResolution>` runs one walk and reports both pieces. The
  ambient_hint dispatch composes per-column prose from the
  result.
- Existing `hint_mode_at_input` / `hint_mode_at_input_with_schema`
  preserved as thinner wrappers for tests / future callers
  that don't need the column name.

**Catalog wrapper** (`src/friendly/strings/en-US.yaml`,
`src/friendly/keys.rs`):
- New `hint.value_slot_for_column: "for `{column}`: {detail}"`
  prefixes the per-type prose with the actual column name when
  the walker has it bound. Schemaless fallback continues to use
  the generic value-literal prose with no column prefix.

**ambient_hint composes** (`src/input_render.rs`): consults
`hint_resolution_at_input`; when `column` is `Some`, wraps the
type prose through `hint.value_slot_for_column`; otherwise
emits the bare type prose.

Tests (846 total, 0 failing):
- 4 new input_render tests assert column names appear in the
  prose at insert/update/where positions plus the
  second-insert-value position (proves column tracking advances
  with comma).
- All existing tests pass unchanged — the column-name addition
  is layered on top of the type-only prose path.

Clippy clean.
2026-05-15 18:33:52 +00:00
claude@clouddev1 82955679ca ADR-0024 Phase D: per-column-type hint prose at value slots
The Phase D commit landed parse-time validation but not the
user-facing payoff — per-column-type hints. Typing
`insert into Customers values (` rightfully expected a hint
like "Type an integer (e.g. 42, -7) or null" at an int column.
This commit closes that gap.

End-to-end:

**`Node::TypedValueSlot { ty, inner }`** (new variant in
`src/dsl/grammar/mod.rs`):
- Walker walks `inner` to consume the literal but tags
  `WalkContext::pending_value_type = Some(ty)` on entry, then
  clears it on a successful inner match. Positions BETWEEN
  slots (`insert into T values (1` mid-input) thus don't carry
  a stale hint type.

**Typed slot factories wrapped in `TypedValueSlot`**
(`src/dsl/grammar/shared.rs`):
- `INT_SLOT`, `REAL_SLOT`, `DECIMAL_SLOT`, `BOOL_SLOT`,
  `TEXT_SLOT`, `DATE_SLOT`, `DATETIME_SLOT`, `BLOB_SLOT`,
  `SERIAL_SLOT`, `SHORTID_SLOT` — each pairs an inner literal
  Choice with its `Type` so the walker can tag context.
- `slot_for_type(ty)` dispatches to the appropriate constant.
- Bug fix: `ShortId` previously dispatched to `INT_SLOT` (a
  pre-Phase-D holdover from the chumsky-side generic
  fallback). `shortid` columns store base58 text (ADR-0011
  fk_target_type shortid → text); the corrected slot accepts
  `StringLit` or `null`.

**Schema-aware hint resolver** (`src/dsl/walker/mod.rs`):
- `hint_mode_at_input_with_schema(source, &SchemaCache) ->
  Option<HintMode>` is the new public entry point. Reads
  `pending_value_type` from the walker's WalkContext and
  emits `HintMode::ProseOnly("hint.value_slot_<type>")` —
  one per Type.
- The schemaless `hint_mode_at_input(source)` falls back to
  the generic `hint.value_literal_slot` at value-literal slots
  (no per-type narrowing without a schema).
- `catalog_key_for_value_type(ty)` is the type → key
  dispatcher.

**Catalog entries** (`src/friendly/strings/en-US.yaml`,
`src/friendly/keys.rs`):
- 10 new `hint.value_slot_<type>` keys with per-type prose:
  - int/serial → "Type an integer (e.g. 42, -7) or null"
  - real/decimal → "Type a number (e.g. 3.14, -0.5) or null"
  - bool → "Type true, false, or null"
  - text → "Type a quoted string (e.g. 'Alice') or null"
  - date → "Type a quoted date as 'YYYY-MM-DD' or null"
  - datetime → "Type a quoted datetime as 'YYYY-MM-DD
    HH:MM:SS' or null"
  - blob → "Type a quoted blob literal or null"
  - shortid → "Type a quoted shortid (or omit to auto-generate)
    or null"

**Ambient-hint dispatch** (`src/input_render.rs::ambient_hint`):
- Passes the SchemaCache through to
  `hint_mode_at_input_with_schema`, so the live hint panel
  surfaces per-column-type prose as the user types into a
  value slot.

Tests:
- 8 walker-side tests cover insert / update / where typed-slot
  hint dispatch, mid-value no-stale-hint behaviour, and a
  full-coverage routing matrix for every `Type` variant.
- 4 input_render integration tests cover the end-to-end
  ambient_hint path: insert first/second value, update set
  value, and the schemaless fallback to generic prose.

Tests: 842 passing, 0 failing, 1 ignored. Clippy clean.

For the user: typing `insert into Customers values (` against
a Customers table whose first column is `id:int` now shows
"Type an integer (e.g. 42, -7) or null" in the hint panel,
replacing the previous generic value-literal prose. After
typing `1, `, the panel updates to whatever the second column
requires — "Type a quoted string (e.g. 'Alice') or null"
for text, "Type a quoted date as 'YYYY-MM-DD'" for date, etc.
2026-05-15 18:05:38 +00:00
claude@clouddev1 124c1d33e9 add handoff-11: ADR-0024 Phase F/D fully landed; HintMode/Ranker/round-5 closed 2026-05-15 17:53:14 +00:00
claude@clouddev1 8188fa5ee1 ADR-0024 round-5 follow-up: surface tail-Optional expectations
Pre-Phase-D, `save ` parsed as a complete `save` command, so
the completion engine had nothing to mine: `as` never surfaced
as a Tab candidate. This is the round-5 gap the handoffs have
been tracking.

`WalkResult` gains a `tail_expected: Vec<Expectation>` field.
The walker's top-level `Matched` branch copies the outer
shape's skipped-Optional expectations into it. `expected_at_input`
returns `tail_expected` on `Match` so the completion engine
sees the optional-suffix continuations and offers them as Tab
candidates.

`hint_mode_at_input` deliberately does NOT consume
`tail_expected` — surfacing prose like "Type a name" at the
end of a valid command would be misleading. A new private
`expected_for_hint` returns empty on `Match` to preserve this.
The split distinguishes "valid + could continue" (completion
helps) from "invalid + must continue" (hint resolver helps).

Tests:
- `save ` Tab → `as` (new test, the original round-5 gap).
- `messages ` Tab → `short` and `verbose` (same shape).
- Existing `hint_mode_none_for_complete_command` stays green
  because hint resolver ignores tail_expected.
- 830 total passing, 0 failing, 1 ignored. Clippy clean.
2026-05-15 17:50:31 +00:00
claude@clouddev1 abebd7944f ADR-0024 Phase D (full): schema-aware value typing
Schema-aware typed value slots — the central design claim of
ADR-0024 §Phase D. Insert / update / delete value slots now
dispatch on the user-facing column type at parse time, rejecting
mis-shaped input with localised wording instead of waiting for
the bind-time error.

What changed:

**SchemaCache extension** (`src/completion.rs`):
- New `TableColumn { name, user_type }` for per-table column
  metadata.
- `SchemaCache.table_columns: HashMap<String, Vec<TableColumn>>`.
- `SchemaCache::columns_for_table(name)` — case-insensitive
  lookup, mirrors the walker's case-insensitive entry-word
  resolution.

**WalkContext schema plumbing** (`src/dsl/walker/context.rs`):
- `WalkContext<'a>` gains a lifetime and a `schema: Option<&'a
  SchemaCache>`. `WalkContext::new()` keeps the schemaless
  default; `with_schema(s)` is the new schema-aware constructor.

**Parser entry point** (`src/dsl/parser.rs`):
- `parse_command_with_schema(input, schema)` is the new public
  schema-aware variant. `parse_command(input)` becomes a thin
  wrapper that delegates with `None` for back-compat.
- Internal `try_walker_route` accepts an `Option<&SchemaCache>`
  and threads it into the WalkContext.

**Node::Ident writes_table/writes_column** (`src/dsl/grammar/mod.rs`):
- Two new fields on `Node::Ident`. When `writes_table: true` and
  `source: Tables`, the walker writes the matched ident's name
  into `current_table` and resolves `current_table_columns`
  against the schema cache. When `writes_column: true` and
  `source: Columns`, the walker writes the resolved
  `TableColumn` into `current_column`.

**Walker driver DynamicSubgrammar dispatch** (`src/dsl/walker/driver.rs`):
- The `Node::DynamicSubgrammar(factory)` branch now resolves the
  factory at walk time and `Box::leak`s the result so its inner
  static-slice fields (Choice/Seq) have the lifetime the walker
  expects (per ADR-0024 §sub-grammars). The leak is bounded by
  command-shape complexity per walk; per-walk arena is a future
  optimisation.
- `walk_ident` extends to perform the schema writes when the
  flags are set.

**Typed value slot factories + dynamic sub-grammars** (`src/dsl/grammar/shared.rs`):
- `int_slot` / `real_slot` / `decimal_slot` / `bool_slot` /
  `text_slot` / `date_slot` / `datetime_slot` / `blob_slot` —
  one per `Type`. Each accepts the appropriate literal kind plus
  `null`; integer-only validator rejects `3.14` at int columns;
  decimal validator pins numeric shape.
- `slot_for_type(ty) -> Node` is the dispatcher.
- `current_column_value(ctx) -> Node` is the dynamic sub-grammar
  for `set col = …` and `where col = …` values; reads
  `current_column` and dispatches via `slot_for_type`.
- `column_value_list(ctx) -> Node` is the dynamic sub-grammar
  for `insert into T values (…)`; reads `current_table_columns`
  and unfolds a Seq of typed slots separated by commas.
- Both fall back to the schemaless `VALUE_LITERAL` choice when
  the context lacks the schema-resolved entries — keeps
  schemaless `parse_command` callers (tests, replay path)
  working.

**Data-command grammar wires the new types** (`src/dsl/grammar/data.rs`):
- `TABLE_NAME_INSERT` / `TABLE_NAME_WRITES` (new): table-name
  slots that set `writes_table: true`. Used by insert / update /
  delete to populate `current_table_columns`.
- `SET_COLUMN` / `FILTER_COLUMN` (new): column-name slots in
  `set col=…` / `where col=…` set `writes_column: true`.
- `INSERT_VALUES_LIST` becomes `DynamicSubgrammar(column_value_list)`.
- `UPDATE_ASSIGNMENT` and `WHERE_CLAUSE` use
  `PER_COLUMN_VALUE = DynamicSubgrammar(current_column_value)`.

**Runtime plumbs schema-with-types** (`src/runtime.rs`):
- `refresh_schema_cache` calls `describe_table` for each table
  and populates `SchemaCache::table_columns` with
  `TableColumn { name, user_type }` entries. Best-effort: a
  `describe_table` miss leaves that table unpopulated and the
  walker falls back to schemaless dispatch.

**App dispatches with schema** (`src/app.rs`):
- `dispatch_dsl` routes through `parse_command_with_schema(&self
  .schema_cache, …)` so live typing/dispatch sees the typed
  slots. The replay path stays schemaless (deferred — replay
  bind-time errors still catch type mismatches).

**Catalog** (`src/friendly/strings/en-US.yaml`, `src/friendly/keys.rs`):
- New `parse.custom.bind_type_mismatch` entry with `{found}` and
  `{expected}` placeholders. Surfaced by the int_slot /
  decimal_slot validators.

Tests:
- 11 new walker-side Phase D tests cover insert / update /
  delete with schemas — typed acceptance per column, decimal
  rejection at int columns, null acceptance at any slot,
  multi-assignment per-column dispatch, schemaless fallback.
- The pre-existing `parse_command(input)` test suite (no
  schema) still passes — the fallback path is behaviour-
  preserving.
- 828 passing total, 0 failing, 1 ignored. Clippy clean.
2026-05-15 17:45:56 +00:00
claude@clouddev1 85817791dc ADR-0024 HintMode dispatch via walker_hint_mode_at_input
Adds the `HintMode` dispatch layer the ADR specified: the
ambient-hint resolver now consults a single
`walker::hint_mode_at_input(source) -> Option<HintMode>` to
decide between the prose / candidates ladder, rather than
discovering each slot kind through three separate post-hoc
helpers (`value_literal_hint_at_cursor`,
`typing_name_at_cursor`, and so on).

Behaviour at slot positions today:

- **Value-literal slot** (`null`/`true`/`false`/number/string
  all in the expected set) → `HintMode::ProseOnly
  ("hint.value_literal_slot")`. The ambient-hint ladder
  emits the catalog prose at empty prefix; once the user types
  a partial (`n`, `tr`, `fa`) the partial check declines and
  normal candidate completion takes over.
- **NewName ident slot** → `HintMode::ForceProse
  ("hint.ambient_typing_name")`. The ladder still consults
  `typing_name_at_cursor` to learn what comes after the name
  (the post-name probe is unchanged); `ForceProse` is the
  declarative tag telling the resolver *that* we're in this
  mode.

`HintMode` itself gains `PartialEq + Eq` for tests, and
its docstring is rewritten to describe the live semantics.

This is the structural shape ADR-0024 §HintMode-per-node
describes: one slot → one hint mode → one dispatch arm. The
detection inside `hint_mode_at_input` is transitional — it
pattern-matches the walker's expected-set today, which is
exactly what the previous ad-hoc detectors did. Phase D will
replace the signature match with node-attached `HintMode`
annotations on the typed value slots (so `date_slot`,
`int_slot`, etc. each carry a type-specific catalog key).

Two helpers move into `input_render.rs`:
- `hint_leading_slice(input, cursor)` mirrors the look-back
  used by `candidates_at_cursor` so the hint resolver sees the
  same token-boundary view of the world.
- `cursor_partial_is_empty(input, cursor)` distinguishes
  empty-prefix from in-progress identifier shapes.

8 new walker tests pin the hint-mode resolver across
value-literal-after-paren, value-literal-after-set-assign,
value-literal-in-where, two NewName-slot cases, the
entry-keyword position, the complete-command position, and
the schema-ident position.

Tests: 817 passing, 0 failing, 1 ignored. Clippy clean.
2026-05-15 17:32:17 +00:00
claude@clouddev1 7ae1a0fde1 ADR-0024 ranker hook scaffolding
Adds the `Ranker` plug-in point ADR-0024 §ranker-layer
specified. The grammar tree declares *what's valid*; the
ranker decides *what's likely useful first*. Default
`identity_ranker` preserves declaration order from the
grammar.

API:
- `pub type Ranker = fn(Vec<Candidate>) -> Vec<Candidate>;`
- `pub const fn identity_ranker(c) -> Vec<Candidate>` returns
  its input unchanged.
- `candidates_at_cursor_with(input, cursor, cache, ranker)`
  applies a custom ranker; the default `candidates_at_cursor`
  delegates with `identity_ranker`.

Three new tests cover identity preservation, custom reordering,
and the empty-list-collapses-to-None edge.

This is a future-work hook — no production caller passes a
non-identity ranker yet. Hooks for frequency-based ranking,
content-aware priors, or recency plug in here without touching
the grammar declarations.

Tests: 809 passing, 0 failing, 1 ignored. Clippy clean.
2026-05-15 17:28:11 +00:00
claude@clouddev1 bbe12524ab ADR-0024 Phase F (full) step 5: walker-driven completion
Replaces the `ParseError::Invalid::expected: Vec<String>`
round-trip with structured `Expectation`s direct from the walker
(ADR-0024 §architecture). The completion engine no longer parses
formatted strings back into types — `Expectation::Ident { source,
role }`, `Expectation::Word`, `Expectation::Literal`,
`Expectation::Flag`, `Expectation::NumberLit`, and
`Expectation::StringLit` are consumed as enum variants.

New helper:
- `walker::expected_at_input(source) -> Vec<Expectation>`
  consolidates the empty-input case (returns every CommandNode
  entry word), unknown-command-word case (also entry words), and
  walker-engaged case (Incomplete / Mismatch expectations) in one
  place. ValidationFailed and Match resolve to empty.

`completion.rs` refactor:
- `expected_at(leading)` wraps the walker helper; replaces the
  legacy string-based `expected_set`.
- Keyword candidates: filter `Expectation::Word(w)` /
  `Expectation::Literal(s)` to alphabetic-only literals (no
  more string-parsing / `strip_backticks`).
- Type names: detect `Expectation::Ident { source:
  IdentSource::Types }` directly (replaces the `TYPE_SLOT_LABEL`
  magic string).
- Flag candidates: read `Expectation::Flag(body)` and format
  as `--{body}` (replaces backticked-string matching).
- Composite-literal candidates: match against
  `Expectation::Literal("1")` (replaces the backticked-string
  `` `1` ``).
- Schema identifiers: `Expectation::Ident { source, .. }`
  filtered by `source.completes_from_schema()`.
- `is_value_literal_signature` checks for `Expectation::Word`
  values "null"/"true"/"false" and `Expectation::NumberLit` +
  `Expectation::StringLit` variants (replaces backticked-string
  matching).
- `invalid_ident_at_cursor` and `typing_name_at_cursor` adopt
  the same path.

The `typing_name_at_cursor` probe (substitute placeholder and
re-parse) still goes through `parse_command` because the probe
specifically wants the *post-name* expected set — `parse_command`
+ the string `expected` field carries that today. A future
follow-up could thread the structured probe through `walker`,
but the value-add is marginal.

`COMPOSITE_CANDIDATES` opener key changes from `` `1` `` (the
backticked-string the chumsky parser produced) to bare `"1"`
(the Expectation::Literal payload).

Touched modules: `dsl/walker/mod.rs` (new export),
`src/completion.rs` (refactor).

Tests: 806 passing, 0 failing, 1 ignored — every existing
completion test passes unchanged, proving the structured path
is behaviour-preserving. Clippy clean.
2026-05-15 17:26:08 +00:00
claude@clouddev1 044173bd39 add handoff-10: ADR-0024 Phase F (full) steps 1-4 landed; remaining work catalogue 2026-05-15 08:39:56 +00:00
claude@clouddev1 fa994cfb66 ADR-0024 Phase F (full) step 4: catalog token-keyword cleanup
Drops the 47 `parse.token.keyword.*` and 6 `parse.token.punct.*`
catalog entries (and their `KEYS_AND_PLACEHOLDERS` declarations).
Nothing consumes them: the walker renders keyword wording in
`format!(\"`{word}`\")` directly, sourced from grammar-tree Word
literals; punct wording surfaces the same way via
`Expectation::Punct(ch)`.

Structural-class labels (`parse.token.identifier`,
`parse.token.number`, `parse.token.string_literal`,
`parse.token.flag`, `parse.token.end_of_input`) and the lex-error
wordings (`parse.token.error.{bad_flag,unknown_char,
unterminated_string}`) stay. These are not derivable from the
grammar tree and the walker's expected-set / validator paths still
read them.

`friendly::keys::tests::keys_validate_against_catalog` continues to
assert catalog ↔ `KEYS_AND_PLACEHOLDERS` bidirectional coverage,
so the trimmed declaration is pinned against the trimmed catalog.

Tests: 806 passing, 0 failing, 1 ignored. Clippy clean.
2026-05-15 08:35:59 +00:00
claude@clouddev1 266b4c2ef4 ADR-0024 Phase F (full) step 3: delete legacy parser modules
Removes the last consumers of `dsl::lexer`, `dsl::keyword`, and
`dsl::ident_slot`, then deletes the modules.

- `Theme::token_color(&TokenKind)` deleted along with its test;
  `Theme::highlight_class_color(HighlightClass)` is the sole
  highlight-colour mapper (the walker's `per_byte_class` feeds
  it directly).
- `IdentSource` (`dsl::grammar`) absorbs the schema-list /
  expected-label / round-trip semantics that previously lived
  on `IdentSlot`. Adds `completes_from_schema`, `expected_label`,
  and `from_expected_label` methods. The walker's
  `Expectation::Ident { source }` and the schema-lookup request
  on the database worker now share one enum.
- `SchemaCache::for_slot(IdentSlot)` → `for_source(IdentSource)`.
- `Database::list_names_for` and the `Request::ListNamesFor`
  worker variant take `IdentSource`. Internal tables and column
  / relationship lookups dispatch on the same enum.
- `InvalidIdent.slot: IdentSlot` → `InvalidIdent.source: IdentSource`.
  The `invalid_ident_at_cursor` rendering branch in
  `input_render.rs::ambient_hint` updates accordingly.
- Completion's keyword filter (`Keyword::from_word`) becomes
  "backticked items whose payload is all ASCII alphabetic" —
  punct and digit literals still surface through their own
  candidate sources (composite-literal, flag, schema-ident);
  the alphabetic filter excludes them from the keyword bucket.
- `friendly::keys::tests::keyword_and_punct_have_complete_token_vocabulary`
  is dropped. It cross-checked `Keyword::ALL` / `Punct::ALL`
  against catalog entries; both enums are gone. The
  `parse.token.keyword.*` / `parse.token.punct.*` catalog
  entries themselves survive for one more commit (catalog
  cleanup, ADR-0024 §cleanup-pass); the
  `keys_validate_against_catalog` test still pins them.
- Modules deleted: `src/dsl/lexer.rs`, `src/dsl/keyword.rs`,
  `src/dsl/ident_slot.rs`.

Tests: 806 passing, 0 failing, 1 ignored. The drop from 852
reflects the removed module-internal tests (~32 lexer, 7
keyword, 4 ident_slot, 1 theme token_color, 1 friendly keys
keyword/punct), and is the expected outcome.

Clippy clean with `nursery` lints + `-D warnings`.
2026-05-15 08:33:59 +00:00
claude@clouddev1 a41400e532 ADR-0024 Phase F (full) step 2: usage via CommandNode.usage_ids
Migrates parse-error usage-block rendering from the legacy
`dsl::usage::matched_entry` (which scanned a `Vec<Token>` for the
first matched Keyword) to walker-side lookup driven by each
`CommandNode`'s `usage_ids` slice.

`CommandNode.usage_id: Option<&'static str>` becomes
`usage_ids: &'static [&'static str]`. Multi-form families
(`drop`, `add`, `show`) carry every variant — `drop` lists
table/column/relationship templates; `add` lists column /
relationship; `show` lists data / table. The single-shape
commands carry their single catalog key.

App-lifecycle CommandNodes had pointed at non-existent
`parse.usage.app.*` keys (never noticed because the field was
unused); they now point at the real catalog entries
(`parse.usage.quit`, `parse.usage.help`, …).

New helpers in `dsl::grammar`:
- `usage_keys_for_input(source) -> Option<(entry_word, usage_ids)>`
  resolves the first identifier-shape token to a CommandNode and
  returns its usage_ids list. Used by `app::render_usage_block`
  and `input_render::ambient_hint`.
- `entry_words_alphabetised() -> Vec<&'static str>` replaces
  `dsl::usage::entry_keywords_alphabetised`.

`dsl::usage` is deleted. The "available commands:" fallback in
`render_usage_block` now formats entry words as `` `<word>` ``
directly (matching the `parse.token.keyword.*` catalog renders);
the per-keyword catalog wrappers will collapse in the next step
(ADR-0024 §cleanup-pass §F).

`parse_command` and `parse_tokens` slim down:
- `parse_command(input)` no longer pre-lexes — the walker scans
  source bytes directly.
- `parse_tokens` (internal-only `pub` for "future I3/I4 work")
  is removed; its body folded into `parse_command`.
- `unknown_command_error` reads the walker registry directly.

Touched modules also drop their `crate::dsl::lexer::lex` and
`crate::dsl::usage` imports: `app.rs`, `input_render.rs`,
`completion.rs`.

Tests: 852 passing, 0 failing, 1 ignored (down from 860 because
the 8 `dsl::usage::tests::*` tests are gone with the module).
2026-05-15 08:27:16 +00:00
claude@clouddev1 7bdd3987e1 ADR-0024 Phase F (full) step 1: walker-driven highlighting
Replaces the lex()-driven `base_runs` span builder in
`input_render.rs` with `walker::highlight_runs`. The new
walker-side `dsl::walker::highlight` module returns per-byte
`HighlightClass` assignments for every token shape in the source:

- For commands the walker engages on, `WalkResult::per_byte_class`
  is the authoritative source (keyword / identifier / number /
  string / punct / flag).
- Trailing junk past a partial match — and inputs the walker
  doesn't engage on at all (no registered entry word) — fall
  through to a byte-shape scanner over `lex_helpers` so unknown
  command words, stray punctuation, and unterminated strings
  still highlight sensibly.

`Theme::highlight_class_color` is the walker-side analogue of
`token_color(&TokenKind)`; the renderer reads `walker::highlight_runs`
output and looks up colours through it. `token_color` and the
`lex()` pre-pass remain in place for now — the lexer module is
still consumed by usage rendering and completion until the
remaining Phase F steps land.

`HighlightClass`'s and `WalkResult::per_byte_class`'s
`#[allow(dead_code)]` annotations come off — they're now part of
the production highlight path.

Tests:
- 16 new tests under `dsl::walker::highlight` cover end-to-end
  walks, byte-shape fallbacks (unknown commands, bare flags,
  numbers, punctuation), UTF-8 codepoint advance, and trailing-
  token handling after partial walks.
- Existing `input_render` tests pass unchanged.
- 860 total tests passing (727 lib + 133 integration), 1 ignored.

Clippy clean with `nursery` lints + `-D warnings`.
2026-05-15 08:19:52 +00:00
claude@clouddev1 b3d3bdfe5b add handoff-9: ADR-0024 phases A-F minimal landed; deferred work catalogue 2026-05-15 07:58:19 +00:00
claude@clouddev1 c940ba9cf2 ADR-0024 Phase F (minimal): drop chumsky from the parse path
Delete the chumsky-side command_parser and its per-command
sub-parsers, error humanisation helpers, and keyword/punct/
ident/value-literal combinators. The unified-grammar walker
in `crate::dsl::walker` is now the sole parse path.

parse_tokens flow (post-Phase F):
1. lex(input) — still produces the Token stream that
   completion / highlighting / echo-line consumers depend on.
2. try_walker_route(source) — walker handles every entry
   keyword in REGISTRY (20 commands across 14 entry words).
3. unknown_command_error(source) — synthetic ParseError for
   inputs whose first identifier-shape token isn't a
   registered entry word. Wording mirrors the chumsky-side
   "expected one of `add`, `change`, …, found `<word>`"
   structural error the legacy top-level Choice produced.

Cargo.toml: chumsky dependency dropped (no remaining uses).
Cargo.lock regenerated; ~58 lines net reduction in the
dependency graph.

Scope intentionally deferred (separate follow-up):
- dsl/lexer.rs, dsl/keyword.rs, dsl/ident_slot.rs,
  dsl/usage.rs::REGISTRY: still consumed by completion.rs,
  input_render.rs, app.rs, theme.rs, db.rs, runtime.rs,
  friendly/keys.rs. Removing these requires migrating each
  consumer to the walker's per-byte-class output / grammar
  REGISTRY / IdentSource enum. Substantial blast radius;
  worth a dedicated session.
- parse.token.keyword.* catalog entries (40+): used by
  usage.rs and parse-error rendering for the unmigrated
  consumers above. Collapse follows after the consumer
  migration.

Tests:
- All existing parser tests (`dsl::parser::tests`) ported in
  place; they call `parse_command` which now flows through
  the walker. 844 passed, 0 failed, 1 ignored — same count
  as Phase E (no test additions, no regressions).
- cargo clippy --all-targets -- -D warnings clean.
- cargo build (release-like dev profile) succeeds.
2026-05-15 07:31:43 +00:00
claude@clouddev1 dca472f8a5 ADR-0024 Phase E: replay end-to-end
Migrate `replay <path>` to the walker. Shape is
Choice(StringLit, BarePath); the StringLit branch handles the
quoted form (with the existing `''` escape), and BarePath
handles the unquoted form.

Per ADR-0024's path-bearing UX change (already shipped for
import / export in Phase A), bare `replay` paths terminate at
the first whitespace byte. Paths with spaces require the
quoted form. The legacy `try_parse_replay_with_bare_path`
source-slice helper in dsl/parser.rs is removed; the
chumsky-side replay branch in command_parser stays declared
but unreachable until Phase F sweeps the chumsky path.

Tests:
- 7 new walker-specific tests for replay: bare relative path,
  bare absolute path, quoted with whitespace, quoted with
  escaped quote, case-insensitive keyword, missing-path
  error, empty-quoted-path parses to empty (runtime layer
  rejects).
- Total: 844 passed, 0 failed, 1 ignored (was 838 / 1).
- cargo clippy --all-targets -- -D warnings clean.
2026-05-15 07:23:51 +00:00
claude@clouddev1 c2accc2385 ADR-0024 Phase D: data commands at chumsky parity
Migrate the four data commands at four entry words: show
(show data / show table), insert, update, delete. Walker now
owns the entire command set introduced through ADR-0014.

Scope deviation from ADR-0024: full schema-aware value typing
via DynamicSubgrammar(column_value_list) is deferred. The
walker accepts any value at any position — matching the
existing chumsky parser's behaviour, where per-column type
checks happen at bind time. The DynamicSubgrammar Node
variant and WalkContext schema fields stay declared so the
infrastructure is in place when the schema cache plumbs
through parse_command (a future refinement). All existing
tests pass on the new shape.

Walker extensions:
- StringLit terminal — wired to the consume_string_literal
  helper that mirrors the legacy lexer's `''` escape handling.
  MatchedItem text carries the unescaped payload; span covers
  the surrounding quotes.
- Bridge: Incomplete error wording now appends `, found end
  of input` (matching the chumsky-side structural error
  contract that `structural_error_for_show_data_without_arg`
  asserts on).

Grammar:
- src/dsl/grammar/data.rs: SHOW (Choice of show_data /
  show_table), INSERT (three forms folded into a single shape
  via a Choice ordered to disambiguate Form B's `values`
  keyword from Forms A/C's `(`-prefixed content; the inner
  paren list is a Choice(VALUE_LITERAL, Ident{Columns}) with
  VALUE_LITERAL ordered first so `true`/`false`/`null` match
  their Word branch rather than the broader identifier catch-
  all), UPDATE (assignments + filter), DELETE (filter).
- VALUE_LITERAL = Choice(Word("null"), Word("true"),
  Word("false"), NumberLit, StringLit) — matches the chumsky
  `value_literal()`.
- WHERE_CLAUSE / FILTER_CLAUSE shared between update and
  delete.
- AST builders walk MatchedPath items in order, using role
  tags (`update_set_column`, `filter_column`,
  `insert_first_item`) to discriminate column references
  belonging to different shapes within the same command.

Tests:
- 13 new walker-specific tests covering all data forms:
  show data / show table, insert with each of three forms,
  insert with negative numbers, update with single + multiple
  assignments + where, update with --all-rows, delete with
  where, delete with --all-rows, update/delete without filter
  errors, replay still routes via chumsky.
- Total: 838 passed, 0 failed, 1 ignored (was 825 / 1).
- cargo clippy --all-targets -- -D warnings clean.
2026-05-15 07:20:53 +00:00
claude@clouddev1 6bb688251b ADR-0024 Phase C: create table with column-list value literals
Migrate `create table <Name> [with pk [<col>:<type>[, ...]]]`
to the walker. Exercises Repeated{separator: Some(Punct(','))}
for the first time — the with-pk column-spec list.

Walker behaviour changes:
- Optional now backtracks on partial-match failure (Incomplete
  or Failed-Mismatch from a Seq mid-shape). Path / per-byte
  state rolls back to before the partial attempt; the inner's
  expected-set propagates as `skipped` so callers see "what
  would have completed it". Matches chumsky's `or_not`
  semantics. ValidationFailed (content errors) does NOT
  backtrack — the user means to fix those.
- Bridge: ValidationFailed errors now classify as
  `at_eof = true`, mirroring the chumsky-side custom-error
  convention. This is what lets `create table Customers`
  classify as IncompleteAtEof rather than DefiniteErrorAt
  (the user can still continue typing `with pk …`).

Grammar:
- src/dsl/grammar/ddl.rs gains CREATE: shape is
  Seq(Word("table"), Ident{NewName,table_name}, Optional(WITH_PK))
  where WITH_PK = Seq(Word("with"), Word("pk"),
  Optional(Repeated{COL_SPEC, separator: Punct(','), min:1})).
  AST builder enforces `with pk needs at least one column`
  with the existing parse.custom.create_table_needs_pk catalog
  wording; `with pk` alone defaults to id:serial.

Tests:
- 6 new walker-specific tests for create_table: with-pk
  default, named typed PK, compound PK, whitespace tolerance
  around `:` and `,`, bare-create-table-errors-with-with-pk-
  hint, case-insensitive keywords.
- Total: 825 passed, 0 failed, 1 ignored (was 819 / 1).
- cargo clippy --all-targets -- -D warnings clean.
2026-05-15 07:12:22 +00:00
claude@clouddev1 7e79ca865a ADR-0024 Phase B: DDL commands without value literals
Migrate the five DDL commands at four entry words: drop (drop
table / drop column / drop relationship), add (add column /
add 1:n relationship), rename (rename column), change (change
column). The walker route now owns these end-to-end; chumsky
declarations remain unreachable for these inputs but stay
until Phase F.

Walker extensions:
- New node kinds: NumberLit (with optional content validator)
  and Literal(&str) (verbatim byte sequence with word-boundary
  lookahead — used for the `1` in `add 1:n …` so it surfaces
  as `\`1\`` in the expected-set, matching the existing
  parse_error_pedagogy contract).
- Flag (--name) terminal — Phase A stubbed; now wired to the
  walker driver with consume_flag() in lex_helpers.
- Repeated combinator with optional separator and `min` floor.
  Used by referential clauses (0..2 `on <delete|update>` runs)
  and change-column flags (0..N --force-conversion /
  --dont-convert; AST builder enforces mutual exclusion).
- Optional now propagates its inner's expectations as a
  `skipped` field on the Matched result. Seq accumulates these
  across children so the next failure's expected-set surfaces
  the full union — closes the keyword-completion regression
  (`add column ` must offer `to`, `table`, plus the table-name
  identifier slot).
- Expectation::Ident gained a `source: IdentSource` field; the
  parser-side bridge maps Tables/Columns/Relationships/Types
  to the IdentSlot::expected_label strings ("table name",
  "column name", …) so the existing completion engine's
  schema-cache lookup still resolves.
- Walker error wording now includes "after `<consumed>`,
  expected …" framing — matches the chumsky-side test
  contract for structural errors mid-shape.
- AST-builder validation errors now propagate as
  WalkOutcome::ValidationFailed (not the generic "AST builder
  failed" fallback), so `change column … --force-conversion
  --dont-convert` and repeated `on delete` clauses surface
  their friendly catalog wording verbatim.

Grammar additions:
- src/dsl/grammar/shared.rs: type-name validator (TYPE_VALIDATOR
  uses Type::from_str via parse.custom.unknown_type catalog),
  qualified_column sub-grammar, referential action keyword
  (`cascade`/`restrict`/`set null`/`no action`), repeated
  on-clauses.
- src/dsl/grammar/ddl.rs: drop/add/rename/change CommandNodes
  with inline shapes (per-use-site `role` annotations let the
  AST builder discriminate parent vs child columns, etc.).
  The four entry words each have one CommandNode whose `shape`
  is a Choice across sub-forms.

Tests:
- 14 new walker-specific tests covering all DDL forms (bare
  drop table, drop column with optional connectives, drop
  relationship by name and by endpoints, add column with type
  validator, rename column, change column with each flag form
  + mutual-exclusion check, add 1:n relationship minimal /
  full, repeated-clause-twice rejection).
- Total: 819 passed, 0 failed, 1 ignored (was 805 / 1).
- cargo clippy --all-targets -- -D warnings clean.
2026-05-15 06:59:27 +00:00
claude@clouddev1 50b3542050 ADR-0024 Phase A: walker framework + app-lifecycle commands
Stand up the unified-grammar tree walker alongside the existing
chumsky parser and migrate the eleven app-lifecycle commands
(quit, help, rebuild, save / save as, new, load, export, import,
mode, messages) end-to-end. The router in parse_tokens consults
the walker first; non-migrated commands still fall through to
chumsky.

Scope:
- src/dsl/grammar/{mod,app}.rs: Node enum (13 kinds), Word /
  IdentSource / HintMode / HighlightClass / ValidationError /
  CommandNode types, REGISTRY of the eleven app commands.
- src/dsl/walker/{mod,driver,context,outcome,lex_helpers}.rs:
  scannerless byte-level walker, per-node-kind dispatch with
  Choice/Seq/Optional backtracking, WalkContext (Phase B-D
  schema fields stubbed), WalkOutcome with Match/Incomplete/
  Mismatch/ValidationFailed.
- src/dsl/parser.rs: try_walker_route() runs first in
  parse_tokens; bridge converts WalkOutcome to ParseError
  preserving catalog wording (mode.unknown / messages.unknown
  surface verbatim via friendly::translate). Legacy
  try_parse_app_path_command deleted; chumsky's bare-keyword
  app branches remain unreachable until Phase F sweep.

Walker design choices worth noting:
- mode <value> / messages <value> use Choice(Word, Word, Ident)
  so known keywords appear in the expected-set; the trailing
  Ident catch-all funnels unknown values into the friendly
  validator that always errors with the catalog wording.
- save / save as is one CommandNode (Optional(Word("as"))) -
  closes the round-5 "save Tab can't offer as" limitation
  structurally.
- Path-bearing UX shipped per ADR-0024: BarePath terminates at
  whitespace; paths with spaces use the (not-yet-wired) quoted
  form. Existing tests pass on the new shape.

Tests:
- 28 new walker-specific tests in dsl::walker::tests covering
  every app-lifecycle command, friendly-error wording for
  mode/messages unknown values, trailing-garbage detection,
  whitespace tolerance, and routing fall-through.
- Total: 805 passed, 0 failed, 1 ignored (was 777 / 1).
- cargo clippy --all-targets -- -D warnings clean.
2026-05-15 06:39:29 +00:00
claude@clouddev1 3e1ff83f26 add handoff-8: round 5/6 summary + ADR-0024 execution handoff
Round 5 + 6 testing + i18n sweep + thiserror migration +
ADR-0023 (direction) + ADR-0024 (accepted execution plan).

Frames the next session's primary task: execute ADR-0024
Phases A-F non-interactively. Spells out escalation criteria
("ADR doesn't cover this" not "I'm unsure which choice is
better"), per-phase commit checkpoints as the user
touchpoint, and the autonomous-by-default disposition the
user has explicitly requested.

Smoke test refreshed (empty hint wording, value-literal slot
hint, q removed, catalog-driven CLI error). Sharp-edges
section covers the design's traps (scannerless walker,
schema-aware parse, WalkContext writes, DynamicSubgrammar
expansion, Optional continuations closing the round-5 'save
Tab can't offer as' gap, IdentSource::Types replacing the
TYPE_SLOT_LABEL magic string, path-bearing UX change
requiring quoting for paths-with-spaces, hand-curated help
text staying out of grammar-derivation scope).
2026-05-14 21:57:33 +00:00
claude@clouddev1 74c3ec1edf add ADR-0024: unified grammar tree execution plan (accepted)
Concrete specification for the direction in ADR-0023, landed
during the round-6 design pass. Resolves all four rounds of
open design questions: walker as single source of truth,
scannerless terminal vocabulary (~8 building blocks), typed
value slots with content validators, WalkContext for schema-
aware narrowing from day one, WalkOutcome multi-purpose
return, HintMode per-node, ranker as separate layer, static
+ dynamic sub-grammars, aliases as Word annotations,
IdentSource taxonomy, six-phase per-command migration with
chumsky and walker side-by-side during the transition.

Key shifts from ADR-0023's sketch:

- Lexer dissolves entirely. Walker operates on bytes directly.
  dsl/lexer.rs, dsl/keyword.rs go away in Phase F.
- Schema-aware parse from day one (not phased). Typed value
  slots reject mis-shaped input at parse time with localised
  wording. Completion narrows per column type.
- Sub-grammars: static (fn() -> Node) for composition;
  dynamic (fn(&WalkContext) -> Node) for schema-dependent
  expansion. No global named registry.
- Path-bearing commands: BarePath becomes a routine
  non-whitespace terminal. Paths with spaces require quoting
  via StringLit (UX simplification, aligns with standard CLI
  convention).
- 13-node taxonomy: Word, Punct, Ident, NumberLit, StringLit,
  BlobLit, Flag, BarePath, Choice, Seq, Optional, Repeated,
  DynamicSubgrammar.

Migration plan: Phase A (walker scaffolding + app-lifecycle
commands), Phase B (DDL without value literals), Phase C
(create table), Phase D (data commands with full schema
awareness -- the design's central claim landing), Phase E
(replay), Phase F (delete chumsky + lexer + legacy parser
modules, simplify catalog). Estimated ~4 sessions total.

Also: rename ADR-0023 from 0023-proposed-unified-grammar-tree.md
to 0023-unified-grammar-tree.md (git mv preserves history)
and update its status to reflect the direction-accepted-but-
superseded-for-execution-detail relationship with ADR-0024.
Index updated.
2026-05-14 21:52:10 +00:00
claude@clouddev1 3b36bbb4d6 hint: replace misleading "null true false" suggestions at value slots
At value-literal slots (`insert into T values (`, `update T set
col=`, `where col=`, comma positions) the expected-token set
contains null/true/false/number/string-literal. The completion
engine was surfacing the three keyword candidates as Tab options
— actively misleading because the user is usually about to enter
a number, quoted text, or date, and seeing "null true false"
implies those are *the* options. User report (round-6 testing):
"especially not when I'm trying to insert a datetime value and
don't know the correct format for the literal".

Fix: detect the value-literal slot by its expected-set
fingerprint. Suppress Tab candidates at empty prefix. Surface a
prose hint listing all literal forms with format examples
('YYYY-MM-DD' for dates, 'YYYY-MM-DDTHH:MM:SS' for datetimes).
Once the user starts typing a prefix (n / tr / fa), normal
keyword completion still applies.

Schema-aware narrowing (show ONLY the datetime format at a
datetime column) waits on ADR-0023.

Tests: 769 -> 777 passing (+8). Clippy clean.
2026-05-14 20:40:19 +00:00
claude@clouddev1 d6e138169f add ADR-0023 (proposed): unified declarative grammar tree
Captures the architectural critique surfaced during round-5
manual testing — that adding a keyword or command currently
requires edits in 7-10 files across parser, completion, usage
registry, catalog, and tests — and the proposed direction: a
single declarative trie registry that drives parse, completion,
highlight, and usage rendering from one source.

Status: Proposed. Not yet accepted. Filename carries the
`-proposed-` segment so status is visible at directory-listing
time; rename to `0023-unified-grammar-tree.md` on acceptance.

Estimated cost: ~4 sessions, per-command migration. Why not
now: feature backlog and bearable scatter cost. Right moment
to execute when backlog quiets or scatter cost becomes
visibly painful.
2026-05-13 22:36:42 +00:00
claude@clouddev1 a55b6a7a05 remove q quit alias
`q` was introduced in round-5 as a peer Keyword variant alongside
`quit`. Per ADR-0023's "alias miss" critique, that was the wrong
shape — it surfaced `q` as a standalone command in completion
(only one of its kind), and required parallel parser + usage +
catalog + test entries. Drops the Keyword variant entirely; if
this ever needs to come back, it should arrive as an alias
annotation per ADR-0023, not as a peer keyword.

Tests still 769 passing.
2026-05-13 22:36:42 +00:00
claude@clouddev1 6ca297579e round-5 follow-up r2: migrate all thiserror Display attributes to catalog
Completes the i18n sweep started in the previous commit. All
remaining hand-rolled user-facing English strings inside
thiserror #[error(...)] attributes have been moved into the
catalog. Drops the thiserror dependency entirely.

Twelve error types migrated:

- dsl::action::UnknownAction         → parse.custom.unknown_action
- dsl::parser::ParseError            → parse.error_wrapper + parse.empty
- dsl::value::ValueError             → value.{type_mismatch,format}
- persistence::csv_io::CsvError      → persistence.csv.*
- persistence::mod::PersistenceError → persistence.{io,encode}
- persistence::yaml::YamlError       → persistence.yaml.*
- persistence::migrations::MigrateError → persistence.migrate.*
- project::lock::LockError           → project.lock.*
- project::naming::NamingError       → project.naming.*
- project::naming::UserNameError     → project.user_name.*
- project::mod::ProjectError         → project.{path_not_found,...}
- project::mod::SafeDeleteError      → project.safe_delete.*
- archive::ArchiveError              → archive.*
- cli::ArgsError                     → cli.*
- db::DbError                        → db.error.*

Pattern per type: drop thiserror::Error derive, write manual
Display calling crate::t!(), keep #[from] semantics via
explicit From impls, override Error::source() where applicable
so #[source]-style chaining is preserved.

Why this matters (user rationale): "fine to have fallbacks for
errors that are purely technical, but lift the output to a
place where it can be localized later and where an adjustment
with friendly text is easily possible if any of them become
part of the happy path." All surface strings now live in
en-US.yaml and can be reworded or localized without touching
Rust source.

Tests: 769 passing, 0 failed, 1 ignored. Clippy clean with
-D warnings. Cargo.toml: drop thiserror = "2.0.18".
2026-05-13 21:24:51 +00:00
claude@clouddev1 1e06490572 round-5 follow-up: completion + i18n sweep
Four user-reported gaps from the round-4 testing pass:

1. Empty-prompt hint reworded from "(no active hint)" to
   "Type a command — press Tab for options, `help` for a
   list" (6 snapshots updated to reflect 80-col truncation).

2. App-lifecycle commands (quit/q, help, rebuild, save/save as,
   new, load, export, import, mode, messages) now flow through
   the DSL parser:
   - 15 new keywords + catalog token entries
   - new Command::App(AppCommand) AST with 11 variants
   - parse-first dispatch in submit() (app commands work in
     both simple and advanced modes)
   - pre-chumsky source-slice for `export <path>` /
     `import <zip> [as <target>]` mirrors the replay precedent
   - UsageEntry registry entries so parse errors surface
     relevant usage templates
   - `mode <bad>` / `messages <bad>` use try_map for the
     friendly "unknown mode/messages" wording

3. DSL completion gaps:
   - `1:n` surfaces as a composite candidate at `add `
   - --all-rows / --create-fk / --force-conversion /
     --dont-convert surface as new CandidateKind::Flag
     candidates (coloured with tok_flag in hint panel)
   - filter_clause .labelled() wrap removed so chumsky's
     expected-set surfaces the constituent options

4. Hardcoded user-facing strings migrated to catalog:
   - 4 parser custom errors (incl. the known "tables need at
     least one column" wart)
   - UnknownType Display now via parse.custom.unknown_type
   - UI panel titles + mode labels (Output / Hint / SIMPLE /
     ADVANCED / Advanced:)
   - app.rs cascade rendering (action labels + summary)
   - runtime --resume CLI stderr
   - db.rs change-column diagnostic tables (7 headers + 3
     wrapper summaries + force-conversion hint)

Tests: 765 → 769 passing, 0 failed, 1 ignored (same doctest
as before). Clippy clean with -D warnings.

Deferred:
- ~25 thiserror #[error] attributes still hand-rolled
  (DbError, ArgsError, ArchiveError, PersistenceError,
  LockError). Tracked separately.
- DSL/SQL relationship in advanced mode — clarified
  implicitly via parse-first dispatch; broader ADR
  amendment to follow.
- Post-complete-parse completion gap (e.g. `save ` Tab
  can't offer `as` because `save` parses bare; same shape
  as `--create-fk` after a complete `add relationship`).
2026-05-13 15:58:29 +00:00
claude@clouddev1 1eb2e0d01f handoff 2026-05-12 09:02:54 +00:00
claude@clouddev1 c247f55094 ADR-0022 follow-up r4: column-type completion
Round-4 user finding: typing `(de` at a column-type slot
showed the parser's "unknown type 'de'" error and Tab did
nothing — completion was blind to the type vocabulary
entirely.

Root cause: type names are NOT in the Keyword enum (ADR-0020
§2 — they remain identifiers, validated by Type::from_str),
so the keyword-iter path in candidates_at_cursor missed
them. The schema-identifier path also missed them (they're
not in the schema cache).

Fix: when the parser's expected-set contains the `"type"`
label (from `ident_inner().labelled("type")` inside
`type_keyword`), produce candidates from `Type::all()`
filtered by the partial prefix. Centralised as
`TYPE_SLOT_LABEL` constant so the parser and the completion
engine agree on the magic string.

Candidates appear in `Type::all()` declaration order
(text/int/real/decimal/bool/date/datetime/blob/serial/
shortid) — matching ADR-0005's pedagogical grouping. Coloured
as Keyword (purple) since type names are closed-set
grammar, not user content.

Verified end-to-end:
  - `(de` → ["decimal"]  (single match → Tab inserts with space)
  - `(da` → ["date", "datetime"]  (multi → cycles)
  - `(sh` → ["shortid"]
  - `(`  → all 10 types in declaration order
  - `(var` → []  (no Tab candidates; parser custom error fires on submit)

Tests: 760 passing, 0 failing, 1 ignored (755 baseline +5
new type-slot cases). Clippy clean.
2026-05-12 07:23:17 +00:00
claude@clouddev1 22119d6a4e ADR-0022 follow-up r3: identifier colour, NewName hint, "Next:" wording, "type" label
Three fixes from a third round of real testing.

1. **tok_identifier vivid (round-3 #1).** The cool grey-blue
   from r2 was still too close to theme.fg to register as
   distinct. Bumped to cyan-teal (#56B6C2 dark / #0F6B76
   light) — identifiers are the user's most "special" content
   and now read that way against keywords (purple), numbers
   (orange), strings (green), and flags (amber).

2. **"Type a name" hint at NewName slots (round-3 #2).**
   New `completion::typing_name_at_cursor(input, cursor)`
   returns `Some(TypingName)` when the cursor sits at — or
   inside — an `IdentSlot::NewName` position. It probes by
   substituting a single-letter placeholder identifier and
   re-parsing to discover what the parser would expect AFTER
   the name; the hint then reads "Type a name, then `(`"
   instead of the technical "next: `(`" that surfaces once
   the partial identifier has been consumed by the live
   parser. When the probe yields nothing useful (custom
   errors with empty expected, or a complete-on-substitute
   case), falls back to "Type a name".

   New catalog keys hint.ambient_typing_name and
   hint.ambient_typing_name_then. Wired into ambient_hint
   between the candidate-list and invalid-ident checks.

3. **"Next:" instead of "expected:" wording.** "Expected"
   read as a leaked diagnostic; "Next:" is shorter,
   conversational, and consistent with the action-oriented
   voice of "Submit with Enter" and "Type a name". Hint
   sentences now also start capitalised
   (Submit/Next/Type/No-such), per the user's Capital-T-on-
   "type a name" preference.

4. **type_keyword labelled "type".** Without a label, the
   `select_ref!` over an Identifier token produced
   `RichPattern::SomethingElse`, which rendered as the
   meaningless "something else" in the hint after `(`.
   Labelled now: error reads "Next: type" — terse but
   honest. The label is applied BEFORE try_map (not after,
   not via as_context) so the existing custom-error wording
   for unknown types ("unknown type 'varchar' (expected one
   of: …)") still surfaces unchanged.

Tests: 755 passing, 0 failing, 1 ignored (no net change —
+5 typing_name cases, -0 net since one test was reworded
for capitalisation rather than added). Clippy clean.

Smoke probe verifies: "add column to table T: " → "Type a
name, then `(`"; "add column to table T: Name (" → "Next:
type"; "show data Custp" → "No such table: `Custp`"; valid
input → "Submit with Enter".

Note for next testing round: parser-side custom errors
(e.g. the "tables need at least one column" message that
fires for `create table Customers `) still read in
lowercase — they're hand-written in parser.rs source rather
than via the catalog. If the lowercase "tables need…"
intrusion bothers you, easy follow-up.
2026-05-11 22:41:23 +00:00
claude@clouddev1 f94a999e66 ADR-0022 stage 8 follow-up r2: completion UX fixes from real testing
Two concrete behaviour changes from the user's second testing
round:

1. **Single vs multi commit paths.** Previously every Tab,
   even single-candidate, created a memo so Esc/Backspace could
   undo. The downside: with one candidate, repeated Tab "cycled"
   through the same item invisibly — looked stuck. Now:
   - Single candidate → insert with trailing space, no memo.
     The user can keep typing or hit Tab again to fresh-complete
     at the new cursor. (Trade-off: Esc/Backspace no longer
     whole-span undo for unique completions; the user accepted
     this for the chained-Tab fluency.)
   - Multi candidate → insert WITHOUT trailing space, create
     memo for cycling. The natural commit gesture is space —
     pressing it clears the memo and inserts the space normally,
     producing "<chosen> " ready for the next position.
   The "stuck on unique" symptom goes away, and the missing
   trailing space on multi-Tab signals "you're picking; press
   space when you're done" without needing modal affordances.

2. **Keyword candidates in grammar order.** Dropped the
   alphabetical sort in `describe_expected` in favour of
   chumsky's native source-order traversal of `or_not`/`choice`
   chains — empirically this matches the canonical command
   shape. Result: `add column ` now offers `to` before
   `table` (as `add column [to] [table] <Table>:…` reads),
   not `table` before `to` which previously suggested the
   nonsensical `add column table to ...`. Identifiers still
   alphabetised within their group; entry-keyword fallback
   for the no-prefix case stays alphabetical (no source order
   when 10 separate command branches).

Tests: 750 passing, 0 failing, 1 ignored (747 baseline →
+3 net: replaced single-candidate Esc/Backspace tests with
new multi-candidate variants; added the unique-Tab-chains-
naturally case that drove the round-2 fix; kept the
keywords-in-grammar-order test updated to assert
`to`/`table`/identifiers ordering).
2026-05-11 22:27:54 +00:00
claude@clouddev1 bd1cce672d ADR-0022 stage 8 follow-up: fixes from real-app testing
Three fixes from the user's testing run, plus an
investigation note on a fourth.

#4 Sticky hint during cycling. The previous code recomputed
candidates_at_cursor at the post-Tab cursor position, which
made the panel whiplash through "what comes next at the new
cursor" between cycles. ambient_hint now short-circuits to
the memo's stored candidate list while the memo is alive —
so Tab Tab Tab keeps showing the same list with the
selection moving, then snaps to the post-Tab ambient state
once any non-Tab key clears the memo.

#2 Candidate ordering and kind-coloured rendering. New
`Candidate { text, kind: Keyword|Identifier }` carries the
classification through completion, last-completion memo,
and ambient-hint payload. candidates_at_cursor now sorts
keywords first (alphabetical), identifiers second
(alphabetical), and the hint-panel renderer colours keywords
in `tok_keyword` and identifiers in `tok_identifier`.
Keyword-vs-identifier name collisions resolve in favour of
the keyword (rare; the user can still address their table
via different syntax).

#3 tok_identifier no longer matches theme.fg. Identifiers
in the input pane now render in a distinct cool grey-blue
(dark) / dark steel-blue (light), so they stand out from
prose-like default text without competing with keyword
purple. Same colour drives the identifier candidates in
the hint panel for visual consistency input ↔ hint.

Limitation worth knowing: "keywords first, alphabetical"
is not the same as grammatical order. For "add column "
the hint shows `table to` not `to table` — chumsky's
expected-set doesn't preserve combinator-source order, and
encoding it in the registry adds maintenance overhead the
fix doesn't cleanly justify. Marked for future revisit if
it bites.

#1 (Tab does nothing on "add column ") — not reproduced
through App::update. The internal logic works correctly:
"add column " + Tab inserts "Customers ", second Tab
cycles to "Orders ", third to "Thing ". The most likely
explanation is a stale binary or a terminal-level event
intercept (tmux focus, kitty-keyboard protocol differences,
etc.) — needs user verification with a fresh build.

Tests: 747 passing, 0 failing, 1 ignored (744 baseline →
+3: 2 new completion-ordering cases including the
keyword-wins-on-name-collision edge, plus 1 hint-mid-cycle
sticky test). Clippy clean.
2026-05-11 22:12:16 +00:00
claude@clouddev1 8214e4136a ADR-0022 stage 8e: invalid-identifier detection + hint variant
Per the user's #5: "if our candidate selection works
correctly, then entering a character that removes all matches
is the same as entering an invalid token." Closes the loop
between schema cache (8c/8d) and live error feedback (4).

New `completion::invalid_ident_at_cursor(input, cursor, cache)`
returns `Some(InvalidIdent { range, found, slot })` when:
  - the cursor is on a partial identifier-shaped token;
  - the parser's expected-set at the start of that token
    contains a known-set IdentSlot (TableName / Column /
    RelationshipName);
  - no schema entry across those slots prefix-matches the
    typed text.

`render_input_runs` extended to take a `&SchemaCache` and
overlay the invalid-identifier range with `tok_error` —
same visual treatment as the parse-error overlay (4),
unified red signal regardless of which detector fires.

`ambient_hint` extended to surface `hint.ambient_invalid_ident`
when invalid_ident_at_cursor returns Some — wording
"no such {kind}: `{found}`" mirrors ADR-0019's engine-error
voice for consistency. Catalog + KEYS_AND_PLACEHOLDERS
declaration added; validator passes.

Render priority: candidates win over invalid-ident
(if any schema match exists for the partial prefix, the
state is "in-progress completion" not "invalid"). Falls
through to the existing parse-error/incomplete/Valid
framings otherwise.

NewName slots are filtered out at the source — typing
into a "user invents this name" position is never invalid
(per `IdentSlot::completes_from_schema`).

Tests: 744 passing, 0 failing, 1 ignored (738 baseline →
+6: 5 invalid_ident_at_cursor cases covering
unknown-prefix-fires, prefix-match-doesn't-fire,
NewName-immune, no-cursor-token, keyword-slot-immune;
plus 1 ambient_hint integration test). Clippy clean.

This closes ADR-0022. Stages 1-8e together deliver the
ambient-typing-assistance feature: token highlighting,
error overlay, hint panel ambient, hint panel multi-
candidate display with scroll markers, Tab/Shift-Tab
cycling with one-keystroke Esc/Backspace undo, schema-aware
identifier completion, and invalid-identifier live
feedback. Total stage-8 footprint: 5 commits, ~1600 lines.
2026-05-11 21:01:44 +00:00
claude@clouddev1 7a32c13bd5 ADR-0022 stage 8d: schema cache refresh wiring
New `AppEvent::SchemaCacheRefreshed(SchemaCache)` event +
App handler that stores it on `app.schema_cache`.

Runtime helper `refresh_schema_cache(database, event_tx)`
fetches table / column / relationship names via the
`list_names_for` worker request (added in stage 7) and posts
the assembled cache. Wired into every site that already
posts `TablesRefreshed`:
  - `seed_initial_tables` (initial project load).
  - Project-switch path in `handle_project_switch`.
  - `RebuildSucceeded` path.
  - Post-DDL path (`spawn_command`).
  - Post-replay path.

Result: schema-aware identifier completion (added in 8c)
becomes live — Tab on `show data ` offers the actual table
names from the current project, `drop column from T: ` (or
similar) offers existing columns, etc. The cache stays
fresh across DDL and rebuild without per-keystroke worker
round-trips (one refresh per schema-mutating action is
amortised across many subsequent keystrokes).

Best-effort: a failed `list_names_for` for any individual
slot kind leaves that field empty in the cache rather than
suppressing the whole refresh — partial completion beats
no completion.

Tests: 738 passing, 0 failing, 1 ignored (unchanged
total — this stage is wiring, not new test surface; the
synthetic-cache tests from stage 8c remain the regression
net for the completion logic itself). Clippy clean.
2026-05-11 20:57:09 +00:00
claude@clouddev1 51a8d9ac44 ADR-0022 stage 8c: IdentSlot propagation + SchemaCache API
`IdentSlot` gains `expected_label()` and the round-trip
`from_expected_label()`. The four slot kinds map to the
user-facing labels "identifier" (NewName), "table name",
"column name", "relationship name".

`ident_ctx(slot)` now actually applies `slot.expected_label()`
as the chumsky label (was documentation-only after stage 6).
Parser errors and the hint panel's "expected: …" prose now
read with the slot-specific name: "expected table name"
instead of the generic "expected identifier". One parser
test updated accordingly; the four catalog `parse.token.*`
keys are unaffected (the slot labels are a parallel surface).

New `completion::SchemaCache { tables, columns,
relationships }` struct + `for_slot(slot) -> &[String]`
accessor. Empty by default; runtime wiring lands in a
follow-on substage. NewName slots return `&[]`
unconditionally.

`candidates_at_cursor` extended to accept `&SchemaCache`:
when the parser's expected-set includes a slot label,
schema candidates from the cache are added alongside the
keyword candidates. Both sources are then prefix-filtered,
combined, sorted, deduplicated. App::schema_cache field
threaded into both the App-side completion paths and the
ambient_hint computation in ui.

Tests: 738 passing, 0 failing, 1 ignored (730 baseline →
+8: 2 IdentSlot label round-trip tests, 6 completion-with-cache
cases covering table/column/relationship slots, prefix
filtering, empty cache, and NewName-no-candidates).
Clippy clean.

User-visible: identifier completion infrastructure is in
place but the cache is always empty — runtime wiring (the
next substage) will populate it on project load and after
successful DDL, at which point Tab on identifier slots
starts offering schema names.
2026-05-11 20:53:19 +00:00
claude@clouddev1 faebeed588 ADR-0022 stage 8b: hint panel candidate list with scroll markers
Refactor `ambient_hint` to return a richer enum:
  - `Prose(String)` — the existing single-line hint (Valid /
    incomplete-with-no-keywords / definite-error states);
  - `Candidates { items, selected }` — multi-candidate (or
    single-candidate) keyword completion at the cursor.

When `candidates_at_cursor` returns Some, the new
`Candidates` variant wins over the prose framing — the
candidate list is more actionable than "expected: `data` or
`table`". `selected` tracks the live `LastCompletion` memo's
selection_idx for the renderer to highlight.

`render_candidate_line` (new helper in ui.rs):
  - All items fit → render space-separated; selected item
    rendered bold + theme.fg, others theme.muted.
  - Overflow → window centred on the selected item (or
    item 0 with no selection); `< ` / ` >` markers at the
    edges (per the user's #2). Window expands right-first
    then left-first to use available width.
  - Returns `Line<'static>` (items cloned into spans) so the
    caller doesn't fight lifetimes between the
    AmbientHint::Candidates payload and the rendered Line.

Updated callers in ui.rs and input_render tests for the new
signature. Added `ambient_hint_with_memo_carries_selected_index`
test asserting the renderer-side `selected` plumbing.

Tests: 730 passing, 0 failing, 1 ignored (728 baseline →
+2 net: -3 reworked + 5 new candidate-related cases).
Clippy clean.

Stage 8c will plumb identifier completion (schema cache +
candidate fetch from worker on demand or pre-cache) and add
the invalid-identifier hint variant.
2026-05-11 20:48:21 +00:00
claude@clouddev1 06e8d1e769 ADR-0022 stage 8a: non-modal keyword completion + Esc/Backspace undo
Per the user's framing decision: there is no "completion
mode." Tab is just an action that consumes whatever is
expected at the cursor, and the existing always-on hint
panel (stage 5) tells the user what's available.

New `completion` module: `candidates_at_cursor(input,
cursor)` returns a `Completion { replaced_range,
partial_prefix, candidates }` based on the parser's
expected-token set at the cursor position. Filters to bare
keyword candidates only (no punctuation, no descriptive
labels), narrowed by the typed prefix (case-insensitive).

`LastCompletion` memo struct on `App::last_completion`
carries the cycle state: inserted_range, original_text,
candidates, selection_idx. Wrap-around forward/backward
indices.

App key handling (added before the existing matcher):
  - Tab → cycle forward if memo present; else insert first
    candidate; create / advance memo.
  - Shift-Tab → cycle backward if memo present; else
    insert last candidate (alphabetically) so the user can
    jump to the end without cycling through everything.
  - Esc / Backspace while memo alive → restore
    original_text in inserted_range, place cursor at the
    pre-Tab position, clear memo.
  - Any other key → clear memo, then process normally.

The user's symmetry preference was load-bearing here:
"insert with one keystroke, remove with one keystroke."
Both Esc and Backspace honour that — multiple Tab cycles
collapse into one undo. Documented inline.

A single-candidate completion still creates a memo so
Esc/Backspace can undo it. Multiple Tabs in a row cycle
through the candidate list with wrap-around at both ends
(per the user's #2).

Tests: 728 passing, 0 failing, 1 ignored (705 baseline →
+23: 13 completion module + 10 app integration tests
covering Tab, Shift-Tab, cycling, wrap-around, Esc-undo,
Backspace-undo, multi-Tab-then-Esc, memo invalidation by
typing or cursor movement). Clippy clean.

Stage 8b will add multi-candidate hint-panel rendering
with scroll markers (`<` `>`) per the user's #2. Stage 8c
will plumb in identifier completion + invalid-identifier
detection.
2026-05-11 20:43:06 +00:00
claude@clouddev1 aea3224da2 ADR-0022 stage 7/8: schema query plumbing
Add `Request::ListNamesFor { slot, reply }` and the public
`Database::list_names_for(slot)` method. The completion
engine in stage 8 calls this on Tab when the cursor sits
on an identifier-typed slot.

Worker dispatch:
  - TableName → user tables (filters __rdbms_*).
  - Column → distinct column names across all user tables
    (v1 simplification per the stage 6 IdentSlot note: no
    table-context binding; the schema-completion engine in
    stage 8 may refine).
  - RelationshipName → relationship names from the
    __rdbms_playground_relationships metadata table.
  - NewName → short-circuited at the public method (no
    worker round-trip).

Names are returned alphabetised + deduplicated. Filters
respect ADR-0002 — internal __rdbms_* tables never reach
the completion menu (covered by a regression test).

Tests: 705 passing, 0 failing, 1 ignored (700 baseline →
+5 list_names_for cases). Clippy clean.

Stage 8 wires this into the App as a Tab-triggered
completion mode. Note for the next session: stage 8 is by
far the largest of the eight stages — it touches App state
(completion mode), event routing (Tab/arrow/Enter/Esc/letter
behaviour while in completion mode), hint-panel render
variant, candidate filtering, integration tests. Several
fine-grained UX decisions (cursor position after accept,
panel height when candidate list overflows, what closes
the mode) want explicit user input rather than agent
guesswork. See "Stage 8 open questions" in the next
handoff for the list.
2026-05-10 17:50:21 +00:00
claude@clouddev1 6845df1475 ADR-0022 stage 6/8: IdentSlot taxonomy + parser audit
New `dsl::ident_slot` module: IdentSlot enum with four
variants — NewName (user invents), TableName (existing),
Column (existing), RelationshipName (existing). Plus
`completes_from_schema()` accessor for the completion
engine in stage 8.

Deliberate v1 simplification vs. ADR-0022 §8: no TableRef
binding for Column. The completion engine in stage 8 will
either union all columns or determine the table from the
consumed prefix heuristically. The TableRef wrinkle returns
if/when stage 8 needs it.

Parser audit: renamed bare `ident()` → `ident_inner()`
(now private-by-convention) and introduced `ident_ctx(slot)`
wrapper. Every command parser combinator was audited and
each `ident()` call site replaced with the appropriate
`ident_ctx(IdentSlot::…)`:

  - create_table table-name → NewName
  - drop_table → TableName
  - add_column → TableName + NewName
  - drop_column → TableName + Column
  - rename_column → TableName + Column + NewName
  - change_column → TableName + Column
  - show_data / show_table → TableName
  - insert column-list → Column; insert table → TableName
  - update set-LHS → Column; update target → TableName
  - delete target → TableName
  - where-clause LHS → Column
  - relationship `as <name>` → NewName
  - drop relationship by name → RelationshipName
  - qualified_column → TableName + Column
  - with_pk_clause spec name → NewName

The slot tag is currently documentation-only — the wrapper
ignores it and returns ident_inner() unchanged. The
audit's value is ensuring every call site has explicit
intent recorded co-located with the parser combinator. The
completion engine in stage 8 will start consuming the slots
either by re-parsing with awareness or by an explicit
parser-side propagation refactor.

Tests: 700 passing, 0 failing, 1 ignored (698 baseline →
+2 IdentSlot enum tests). Clippy clean.

Stage 7 plumbs schema queries through the worker thread
(ListNamesFor) so stage 8's completion engine has data.
2026-05-10 17:47:02 +00:00
claude@clouddev1 9c4857eb50 ADR-0022 stage 5/8: hint panel ambient typing assistance
ParseError::Invalid gains an `expected: Vec<String>` field —
the human-rendered names of the patterns chumsky was looking
for at the failure point (`\`create\``, `identifier`, etc.).
Empty for custom errors, which have no expected-set framing.
Populated by a new `describe_expected()` helper in parser.rs
that humanise() also delegates to (eliminates duplication).

`input_render::ambient_hint(input) -> Option<String>` returns
the hint-panel content per ADR-0022 §6:
  - empty input → None (caller falls back to panel.hint_empty);
  - Valid → t!("hint.ambient_complete") ("submit with Enter");
  - IncompleteAtEof → t!("hint.ambient_expected", expected = …)
    listing the parser's expected next tokens, oxford-joined;
  - DefiniteErrorAt → t!("hint.ambient_error_with_usage", …)
    composing the parse-error message with the matching
    parse.usage.* template if a known entry keyword was
    consumed, else the bare message.

Catalog gains the three hint.ambient_* keys + validator
declarations.

ui::render_hint_panel resolution order:
  1. explicit app.hint (modal contexts) wins;
  2. simple-mode + non-empty input → ambient_hint;
  3. fallback to panel.hint_empty.
Advanced mode (persistent + one-shot `:`) bypasses ambient
hinting per ADR-0022 §12.

Snapshot: highlighted_input_all_token_classes rebaselined
because the hint panel now displays an ambient hint instead
of the empty placeholder when input is non-empty.

Tests: 698 passing, 0 failing, 1 ignored (693 baseline →
+5 ambient_hint cases). Clippy clean.

Stage 6 introduces the IdentSlot taxonomy + parser audit so
identifier-typed slots can yield schema-aware completion
candidates in stage 8.
2026-05-10 17:42:13 +00:00
claude@clouddev1 313d4f8346 ADR-0022 stage 4/8: render-time parse + error overlay
Add `classify_input(&str) -> InputState` that returns one of
{Empty, Valid, IncompleteAtEof, DefiniteErrorAt(byte)}.
The renderer uses this to overlay tok_error on the failing
token of mid-typed input that can never be valid.

ParseError::Invalid gains an `at_eof: bool` field populated
by `into_parse_error`:
  - structural failures: at_eof = found.is_none()
    (chumsky's own "ran out of input" discriminator);
  - custom errors from try_map: at_eof = true,
    conservatively.

The conservative custom-error classification is a deliberate
under-highlighting bias. It means three classes of error
currently DO NOT get a live red overlay (only on submit):
  - "tables need at least one column" (correct: this is
    genuinely an incomplete state — adding `with pk ...` fixes it);
  - "unknown type 'varchar'" (sub-optimal: should overlay);
  - "--force-conversion and --dont-convert are mutually
    exclusive" (sub-optimal: should overlay).
The trade-off is documented inline on the at_eof field. A
future refinement could carry an explicit definite/incomplete
tag through Custom errors (would change RichReason::Custom's
payload from String to a typed value).

render_input_runs now applies the overlay on the failing
token's run before injecting the cursor. Tokens after the
error keep their lex-class colour — fixes one thing at a
time per ADR-0022 §4. Lex errors continue to render in
tok_error from stage 2.

Pattern-matches on ParseError::Invalid throughout the
codebase use `..` and are unaffected; only the two
constructions in parser.rs needed updating.

Tests: 693 passing, 0 failing, 1 ignored (683 baseline →
+10: 7 classify + overlay tests, +1 adapted full-command
test, +2 valid-vs-incomplete coverage). Clippy clean.

Stage 5 lights up the hint panel as the verbose-feedback
surface — needs the InputState classifier from this stage.
2026-05-10 17:37:50 +00:00
claude@clouddev1 39da399add ADR-0022 stage 3/8: simple-mode echo lines highlighted
Lift `dsl::ECHO_PREFIX = "running: "` as a public const,
with a unit test asserting `t!("dsl.running", input = "")`
matches it. The catalog template is now contracted to equal
`format!("{ECHO_PREFIX}{input}")` — a translator changing
the prefix breaks the test.

Add `input_render::lex_to_runs(input, theme)` — a
cursor-less variant of `render_input_runs` for use cases
(echo lines, future hint panel) that need token-class
colouring without an inverted cursor.

ui::render_output_line: when the line is an Echo submitted
in Simple mode, peel the prefix and re-tokenise the rest
through lex_to_runs, rendering each token at its class
colour. Advanced-mode echoes and any echo whose body
unexpectedly lacks the prefix fall through to the plain
rendering.

Tests: 683 passing, 0 failing, 1 ignored (682 baseline →
+1 echo_prefix_matches_catalog_template). Clippy clean
(uses let-chain to keep the if condition flat).

Stage 4 adds render-time parse + error overlay so the
failing token in mid-typed input lights up in the error
colour.
2026-05-10 17:32:11 +00:00
claude@clouddev1 cafc455c8a ADR-0022 stage 2/8: input panel — token-class highlighting
New `input_render` module with `render_input_runs(input,
cursor_byte, theme) -> Vec<StyledRun>`. Lexes the input,
assigns each token its `theme.token_color`, preserves
whitespace gaps as `theme.fg` runs, and injects the cursor
by splitting the run that contains it into before/under/after
sub-spans (under marked Modifier::REVERSED). End-of-input
cursor is an empty-range sentinel rendered as an inverted
space.

ui::render_input_panel switches over EffectiveMode: simple
mode goes through render_input_runs + a small runs_to_spans
helper that borrows from the input string; advanced modes
(persistent + one-shot `:`) keep the previous plain
before/under/after rendering since the DSL lexer doesn't
speak SQL (ADR-0022 §12).

Multi-byte UTF-8 in string literals is handled by walking
to the next char boundary when splitting the cursor run,
mirroring the previous renderer.

Tests: 682 passing, 0 failing, 1 ignored (672 baseline →
+10: 9 input_render unit tests covering each token class,
cursor placements, multi-byte, full-command shape; +1 new
"all token classes" UI snapshot). Clippy clean.

Caveat (noted inline in the new snapshot test): the
TestBackend/render_to_string path records text symbols
only, not ratatui style. The new snapshot is therefore a
text-layout regression net; the unit tests in
input_render::tests are the authoritative regression net
for colour mappings.

Stage 3 wires the same colouring into simple-mode echo
lines in the output panel.
2026-05-10 17:29:51 +00:00
claude@clouddev1 00c9deaf6f ADR-0022 stage 1/8: theme token-class colour fields
Add seven `tok_*` Color fields to Theme — keyword,
identifier, number, string, punct, flag, error — populated
in both dark and light themes. WCAG-AA contrast against
each theme's bg.

Identifier and punct sit close to fg/muted so dominant
content reads quietly; literals + flags get warm accent
tones; keyword takes a cool accent (purple) distinct from
the mode-banner blue. tok_error reuses the existing error
palette so lex-error tokens read consistently with [error]
lines elsewhere.

New helper Theme::token_color(&TokenKind) -> Color maps
each token kind to its display colour.

Tests: 672 passing, 0 failing, 1 ignored (668 baseline → +4
theme tests). Clippy clean.

Pure addition; no existing render path uses these yet.
Stage 2 wires them into the input panel.
2026-05-10 17:24:44 +00:00
claude@clouddev1 f0632af8af ADR-0022: ambient typing assistance (unifies I3 + I4)
Replaces the originally-planned separate ADRs for syntax
highlighting (I4) and tab completion (I3) with a single
unified design. The framing: colour, hint panel, and Tab
are three answers to the same question — what does the user
need to know mid-typing? — and planning them separately
produces three loose pieces that drift apart.

Three mid-typing states (valid-so-far / definite-error /
incomplete-but-plausible) drive four layered channels:
token-class colour and parse-error overlay (silent, always
on), hint panel ambient and Tab-triggered completion mode
(verbose, in the existing hint panel — no floating popups).

Schema-aware from day one via an IdentSlot taxonomy in the
parser (NewName / TableName / ColumnIn(TableRef::Earlier(N))
/ RelationshipName); every existing ident() call gets
audited and tagged. Completion candidates come from chumsky's
expected-token-set for keyword slots and from a new worker
request (ListNamesFor) for identifier slots.

Implementation lands in 8 green-after-each commits: theme
colours; input panel highlighting; echo line highlighting;
render-time parse + error overlay; hint panel ambient;
identifier-slot taxonomy + parser audit; schema query
plumbing; completion mode + key bindings. Estimated
1500-2500 lines across the eight stages.

Out of scope (deliberately): inline ghost text (could return
as a "most-likely" affordance later — fish-shell style),
fuzzy matching, punctuation completion, user-customisable
keybindings, SQL highlighting in advanced mode (waits on Q4).
2026-05-10 15:51:22 +00:00
claude@clouddev1 11071ae164 ADR-0021 implementation: per-command usage templates in parse errors
New `dsl::usage` module: registry pairing each command's
entry-keyword with a `parse.usage.*` catalog key.
`matched_entry()` resolves the entry keyword from the
consumed token prefix; multi-entry families (add, drop,
show) return all matching keys.

Catalog: new `parse.usage.<command>` keys (one per command),
`parse.token.{keyword,punct,...}` vocabulary (one per
Keyword/Punct variant + token-class labels + LexError
kinds), and `parse.available_commands` for the no-prefix
fallback. Catalog grows ~60 entries.

Validator: extended KEYS_AND_PLACEHOLDERS; new completeness
test asserts every Keyword and Punct variant has its
`parse.token.*` entry.

`app::dispatch_dsl` rewritten to compose three blocks per
ADR-0021 §2: caret + structural/custom error + usage block
(or available-commands fallback per §5). Caret math fixed
to use original-input byte position rather than
trimmed-input position (the lexer no longer trims before
lexing). Three pre-existing app tests adjusted to look
across all error lines instead of `output.back()` (the
usage block is now the last line).

`dsl::usage::matched_entry` uses `<=` rather than `<` for
position comparison so custom errors raised by `try_map`
(whose span starts at the first consumed token) still
resolve to the entry keyword.

Tests: 668 passing, 0 failing, 1 ignored (650 baseline →
+18: 8 usage + 1 token-vocab completeness + 9 new
integration tests in tests/parse_error_pedagogy.rs
covering create/add/drop/show/frobulate/update/insert
cases). Clippy clean.
2026-05-10 14:41:32 +00:00
claude@clouddev1 fdaf7e3e0e ADR-0020 implementation: lexer + parser refactor over &[Token]
New `dsl::keyword` module: macro-driven Keyword and Punct
enums (single source of truth — enum, lex-side mapping,
catalog-key derivation generated from one declaration).

New `dsl::lexer` module: tokenizer producing a span-tagged
Vec<Token>. Always succeeds; lex-shape errors (unterminated
string, unrecognised character, malformed flag) embed as
TokenKind::Error tokens so I4 can highlight invalid input
uniformly.

Parser refactored from `Parser<'a, &'a str, ...>` to
`Parser<'a, &'a [Token], ...>`. All 50+ existing parser
unit tests ported and passing; aggregation across `choice`
now works as designed (e.g. `add` → "expected `1` or
`column`", `drop` → "expected `column`, `relationship`, or
`table`", `frobulate Customers` lists all ten command-entry
keywords). Custom `try_map` content errors (unknown type,
mutually-exclusive flags, "with pk needs at least one
column", "specified twice") preserved.

`replay` bare-path UX kept via the source-slice special
case from ADR-0020 §6 (~10 lines, documented inline).

Tests: 650 passing, 0 failing, 1 ignored (610 baseline + 40
new lexer/keyword tests). Clippy clean.
2026-05-10 09:22:13 +00:00
claude@clouddev1 857ee753f2 ADR-0020 + ADR-0021: tokenization layer and parse-error pedagogy (H1a)
ADR-0020 amends ADR-0001 with a two-phase parse: a lexer
producing a span-tagged token stream, then chumsky over
&[Token]. Single source of truth for keywords and punct via
a define_keywords!/define_punct! macro pattern. Parser
contract committed for I3 (queryable expected-token-set)
and I4 (lexer always succeeds, Error tokens for invalid
input). Includes an honest history note: the no-lexer shape
in dsl/parser.rs arose incrementally without ADR-level
deliberation against the known H1a/I3/I4 requirements; this
ADR corrects that.

ADR-0021 builds on ADR-0020 to close the H1a gap: a
per-command UsageEntry registry keyed off entry-keyword,
with parse errors rendered as caret + structural error +
matching usage template(s). Multi-entry families (add,
drop, show) render together. New catalog sections under
parse.usage.* (per-command grammar) and parse.token.*
(single-token vocabulary). Zero-prefix case ("frobulate
Customers") falls back to an "available commands:" framing.
Anchor-phrase compliance preserved.
2026-05-10 08:43:20 +00:00
claude@clouddev1 47601f7c85 Handoff doc for end of 2026-05-09 (#6)
Documents this session's work and the recommended next move:

## Session totals
- 11 commits since handoff-5
- 534 → 610 tests passing (+76)
- Release binary 7.2 → 7.8 MB

## What landed
- All four non-CI items from handoff-5's Independent Work
  list: B2 (int→bool tests), B1 (help update), A2 (engine-
  vocabulary audit), A3 (replay command)
- ADR-0019 fully implemented end-to-end:
  - Friendly-error layer + i18n catalog (~170 entries
    across 16 categories)
  - §6 runtime row-pinpoint enrichment with
    schema-resolved facts
  - §9 migration sweep — every user-visible literal in
    src/ now flows through the catalog (caught a ui.rs
    gap during the post-sweep manual sanity check, folded
    it in as sweep 3/3)

## Recommended next move
Parser-as-source-of-truth ADR + H1a implementation. The
friendly-error layer made engine errors much better;
parse-error wording is now the visibly-weakest user
surface. User explicitly surfaced the gap during manual
testing this session ("typing `create` should illustrate
the expectation"). Bounded scope, high pedagogical value,
unblocks I3/I4 in passing.

A1 (CI workflow) noted as the easy alternative for a
quick win first.

## Sharp edges captured
- New i18n workflow: catalog + keys.rs + t!() at every
  use site, validator catches drift
- TranslateContext is owned (no lifetime); App combines
  runtime FailureContext with verbosity
- Anchor phrases load-bearing per ADR-0019 §10
- `running: ` prefix coupled to caret-padding math
- main.rs initialises catalog before args parsing
- Several alignment-coupled strings deliberately left out
  of the catalog (echo prefix tags, mode labels)
2026-05-10 07:48:08 +00:00
claude@clouddev1 a6fd26d15a ADR-0019 §9 sweep (3/3): ui.rs prose strings (caught in manual sanity)
Surprise gap from the post-sweep sanity check — `ui.rs` had a
substantial set of TUI-rendered strings that the previous two
sweep passes didn't cover. Caught by grepping for capitalised
literals in `ui.rs` after running the binary smoke check.

## Migrated

- **modal.*** — load picker title / empty state / path
  prompt; rebuild confirm title / "Continue?" prompt.
  (modal.path_entry's title comes from `save.*` since it's
  the save / save-as dialog.)
- **save.*** — `save` no-op hint, modal titles for
  Save / Save as, modal prompt body.
- **status.*** — status bar `Project:` label and the
  `(no project)` placeholder.
- **panel.*** — `Tables` panel title, `(none yet)`
  placeholder for empty tables, `(no active hint)`
  placeholder for the hint panel.
- **shortcut.*** — the bottom-bar keyboard hint labels
  (submit, confirm, cancel, yes, no, load, select,
  browse_path, back_to_list, switch, advanced_once,
  cancel_one_shot, quit). Each is a translatable label
  paired with a key name (Enter / Esc / Ctrl-C / etc.) at
  the call site. Keystroke names are deliberately left as
  literals — translating them would mean retraining users
  away from what their keyboard says.

The `push_shortcut` closure's parameter type changed from
`&'static str` to `&str` so it accepts the catalog-returned
String.

## Deliberately left

- **Echo prefix tags**: `[simple] `, `[advanced] `,
  `[system] `, `[error]  `. Their column widths are
  hardcoded into the wrap-width calculation in
  `render_output_panel`; translating them would silently
  break alignment. Worth a follow-up pass if a future locale
  needs different prefixes (would need `mode.label()` and
  the echo-tag widths to live behind a single locale-aware
  function).
- **Mode labels**: `SIMPLE` / `ADVANCED` / `Advanced:`
  rendered in the input panel border. Same alignment
  reasoning as the echo tags — also they're keywords (the
  user types `mode simple` to switch), so translating the
  display label without translating the command word would
  be confusing. Left as is.
- **Visual decoration**: `[Y]`, `[N]`, `[TEMP] `, `>`
  cursor markers, `█` cursor block, `↑↓` arrow glyph,
  `›` selection marker. Universal symbols / labels rather
  than translatable prose.

## Catalog totals

The catalog now has ~170 entries across 16 categories.
`tests/engine_vocabulary_audit` passes — no engine
vocabulary leaks anywhere user-reachable.

## Tally

610 tests passing (no change — pure refactor with
identical-output catalog substitutions). Clippy clean
with nursery lints. Release builds at 7.8 MB.

ADR-0019 §9 is now genuinely complete.
2026-05-09 22:41:06 +00:00
claude@clouddev1 720511ef29 ADR-0019 §9 sweep (2/2): help blocks + modals + system notes
Final pass of the i18n migration sweep. Every user-visible
string in `src/` now flows through the catalog via `t!()`.

## Categories migrated in this commit

- **help.cli_banner** — the entire `cli::HELP_TEXT` const,
  formerly a 40-line `&'static str`, is now a YAML block in
  the catalog. The const is replaced by a thin
  `cli::help_text() -> String` wrapper that performs the
  catalog lookup. `main.rs` calls `help_text()` for both
  `--help` output and the args-parse error path. The two
  integration tests that referenced `HELP_TEXT` directly are
  updated.
- **help.in_app_body** — the in-app `help` command's body is
  one YAML block; `note_help` becomes 5 lines that iterate
  the lines and emit each as its own output row (preserving
  the renderer's "one logical line = one display row"
  invariant for accurate scroll math).
- **modal.*** — load picker, rebuild confirm, and save-as
  path-entry strings: rebuild_cancelled, load_cancelled,
  generic_cancelled, load_picker_nothing,
  path_entry_empty_name, path_entry_empty_path.
- **dsl.failed** — the `"<verb> <subject>" failed: <rendered>`
  wrapper around the friendly-error layer's translated
  message.
- **dsl.running** — the `running: <input>` echo line shown
  above each command's response. (Note: the en-US prefix
  "running: " is hardcoded in the parse-error caret-padding
  calculation. Translators changing the prefix must keep the
  width consistent — documented inline.)
- **advanced_mode.not_implemented** — the placeholder echo
  shown when SQL hits the unimplemented advanced-mode path
  (Q1 territory).
- **fatal.persistence** — the FATAL banner for
  PersistenceFatal events (ADR-0015 §8).
- **project.{load_path_missing,saveas_target_exists,**
  **import_zip_missing}** — runtime-side project-switch
  validation errors that surface via ProjectSwitchFailed.

## Catalog start-up ordering

`main.rs` now calls `friendly::catalog()` at the very top
(before args parsing) so `help_text()` works in both the
success path and the args-error path. A corrupted build
artefact still fails loudly with a useful panic; the
practical risk is essentially zero since the catalog is
`include_str!`'d at compile time and validated by the unit
test before shipping.

## Remaining literals

The only `note_*` calls in `src/` that still pass plain
strings are inside `#[cfg(test)]` modules — synthetic test
fixtures, not user-visible. The codebase passes the "every
user-visible string flows through the catalog" bar.

## Tally

610 tests passing (no change in count — pure refactor).
Clippy clean with nursery lints.

## What this closes

ADR-0019 §9 (migration sweep) — done.

ADR-0019 itself is now fully implemented:
- §1-§5: catalog + translator + voice + verbosity ✓ (`eac7e5b`)
- §6: row pinpointing + schema enrichment ✓ (`431645a`)
- §9: migration sweep ✓ (this + `aff528a`)
- §10: anchor phrases preserved throughout ✓
- The five "Out of scope" items remain explicitly bounded
  to future ADRs (advanced-mode SQL, settings persistence,
  pluralisation, runtime locale, value formatting,
  constraint management).
2026-05-09 22:29:28 +00:00
claude@clouddev1 aff528aa3f ADR-0019 §9 sweep (1/2): replay/client_side/ok/mode/messages/project/parse
First half of the catalog migration sweep. Six categories of
user-visible literals moved from inline `format!` calls to the
i18n catalog via `t!()`:

- **replay.*** — `[ok] replay … N command(s) run`,
  `replay … failed at line N: …`, the `> command` echo, and
  the inner `could not open` / `parse error` / `nested replay`
  wordings the runtime constructs inside `ReplayFailed.error`.
- **client_side.*** — the four [client-side] pedagogical notes
  from ADR-0017 §6 / ADR-0018 §9 (transformed,
  transformed_lossy, auto_fill_transition,
  auto_fill_add_serial, auto_fill_add_shortid). The
  `format_auto_fill_add_note` helper in db.rs now routes via
  the catalog too.
- **ok.*** — the `[ok] {verb} {subject}` summary header
  (consolidated through a new `App::note_ok_summary` helper)
  plus the per-operation row-count footers
  (`{count} row(s) inserted/updated/deleted`).
- **mode.*** — `mode: simple/advanced` set/show banners +
  `usage: mode …` + `unknown mode '{value}' …` errors.
- **messages.*** — `messages: short/verbose` set/show + the
  `unknown messages mode` error.
- **project.*** — `[ok] rebuild — {summary}`, `[ok] now
  editing: {display_name}`, `[ok] export — wrote {path}`, plus
  matching failure variants and the `usage: export/import`
  + `import: empty target after as` argument-parsing errors.
- **parse.*** — the `parse error: {detail}` wrapper around
  chumsky's structural output, the `{padding}^` caret pointer,
  and the `empty input` fallback for `ParseError::Empty`.

Catalog total: 99 lines of YAML across the new categories,
44 new entries declared in `keys.rs::KEYS_AND_PLACEHOLDERS`.
The validator (`keys_validate_against_catalog`) walks the
expanded list and confirms placeholder coverage / no format
specifiers / no engine vocabulary across every entry.

Anchor phrases (ADR-0019 §10) preserved verbatim; existing
substring assertions in the test suite hold.

## Tally

610 tests passing (no change in count — pure refactor).
Clippy clean with nursery lints. Release builds.

## Still ahead in the sweep

- Sweep 7: HELP_TEXT (CLI banner) + in-app `note_help` —
  large multi-line blocks.
- Sweep 8: modal labels (load picker, rebuild confirm,
  save-as path entry) + any remaining strays. Final pass.

Both shipping in a follow-up commit so this checkpoint
stays reviewable.
2026-05-09 22:20:34 +00:00
claude@clouddev1 431645ae60 ADR-0019 §6: runtime enrichment + row pinpointing
Closes the placeholder-substitution gap reported during manual
testing: FK violations were rendering `<value>` and `<column>`
literally because the App had no schema awareness. With this
change the runtime resolves the schema-dependent facts before
the App ever sees the failure.

## Architecture

- **Database** gains two public methods backed by new worker
  Request variants:
  - `read_relationships(table)` → (outbound, inbound) FK list
    (lifts the previously-private `read_relationships_*` pair
    into the public surface, behind a `RelationshipsReply`
    type alias).
  - `find_rows_matching(table, column, value, limit)` →
    `DataResult` for row pinpoint queries.

- **friendly module** gets:
  - New `FailureContext` struct: schema-resolved facts the
    runtime builds (table, column, value, parent_table,
    parent_column, child_table, optional diagnostic_table).
  - `TranslateContext` loses its lifetime parameter and gains
    `parent_table` / `parent_column` fields. All string fields
    are now `Option<String>` for ownership simplicity.
  - `TranslateContext::from_facts(operation, verbosity, facts)`
    helper.
  - Translator's FK paths now use `ctx.parent_table` /
    `ctx.parent_column` for child-side wording; FK Update gets
    a dedicated `fk_child_side_update` arm.
  - FK dispatch is enrichment-driven first
    (`parent_table` set → child-side; `child_table` set →
    parent-side), with operation as the tiebreaker.
  - The translator forwards `ctx.diagnostic_table` onto the
    `FriendlyError` so pinpointed rows render through the
    existing ADR-0017 §7 bordered renderer.

- **Event** `DslFailed` carries `(command, error, facts)`.
  The runtime populates `facts` via `enrich_dsl_failure`
  before posting the event.

- **Runtime** `enrich_dsl_failure(database, command, error)`
  classifies and resolves:
  - UNIQUE INSERT/UPDATE: parses `T.col` from engine message,
    finds the user's attempted value (with schema fallback
    for natural-order multi-value INSERT — including the
    serial/shortid auto-skip rule from `do_insert`), pinpoints
    the existing conflicting row(s) via `find_rows_matching`
    and renders as a `DiagnosticTable`.
  - NOT NULL INSERT/UPDATE: parses `T.col`; no value
    (definitionally null) and no pinpoint (engine doesn't
    identify the row).
  - FK INSERT/UPDATE: outbound relationship lookup picks the
    FK column the user is touching; resolves
    `parent_table`/`parent_column`/`value`. UPDATE falls back
    to inbound (parent-side) when no outbound match.
  - FK DELETE: inbound relationship lookup picks a child_table
    that references this row.

- **App** drops its old `attempted_value_for` /
  `column_from_qualified_target` helpers (their work moved to
  runtime where the Database is in scope).
  `build_translate_context` combines the runtime-supplied
  facts with the operation derived from the Command and the
  App's verbosity.

## Manual-test fixes folded in

Two issues surfaced during manual testing of the initial
implementation, both fixed:

1. Natural-order multi-value INSERT
   (`insert into Orders values (4, 11.99)`) skipped FK
   enrichment because `user_value_for_column` only knew the
   single-value short form. The schema-aware lookup
   (`user_value_for_column_with_schema`) now mirrors
   `do_insert`'s position-mapping rule (auto-generated
   columns skipped), so positional INSERTs onto tables with
   serial/shortid PKs resolve correctly. Regression test:
   `enrich_fk_insert_natural_order_multi_value_resolves_via_schema`.

2. The arity error on INSERT now lists the columns it
   expected — `expected 3 value(s) for (id, Name, Email), got 2`
   instead of the bare count. Surfaces what the user needs
   to fix without making them go check the schema.

## Tests

`tests/friendly_enrichment.rs` (+8 integration tests):
- UNIQUE INSERT with explicit columns: facts.{table, column,
  value, diagnostic_table} all resolved; pinpoint shows
  conflicting row.
- UNIQUE INSERT natural-order short form: schema fallback
  resolves the value.
- UNIQUE UPDATE: value pulled from assignments.
- NOT NULL INSERT: table+column resolved, value None
  (correct), no pinpoint.
- FK INSERT: parent_table, parent_column, value all resolved
  via outbound relationship lookup.
- FK INSERT natural-order multi-value: schema-aware lookup
  with auto-skip resolves correctly (regression for the
  manual-test bug).
- FK DELETE: child_table resolved via inbound relationship
  lookup.
- DbError::Unsupported: enrichment returns default
  FailureContext (no false positives).

App-level tests updated to populate `FailureContext` directly
(simulating runtime enrichment) for the verbosity / threading
checks.

## Tally

610 tests passing (was 603: +8 enrichment integration tests
minus 1 obsolete App-side helper test that the runtime
absorbed). Clippy clean with nursery lints. Release builds.
2026-05-09 22:10:05 +00:00
claude@clouddev1 eac7e5b81d ADR-0019 implementation: friendly error layer + i18n catalog
All eight implementation steps from ADR-0019's §"Order of
operations":

Step 1 — `src/friendly/` module skeleton; `t!()` macro; YAML
  catalog loader (`include_str!` + `serde_yml`); `{name}`
  substitution helper that rejects format specifiers per §8.4.

Step 2 — `error.*` catalog populated for UNIQUE / FK /
  NOT NULL / CHECK / type-mismatch / not_found / already_exists /
  generic / invalid_value, with verbose hints per
  pedagogical-voice rule (§5). Anchor phrases (§10) preserved
  verbatim.

Step 3 — `FriendlyError { headline, hint, diagnostic_table }`
  + renderer composing the three blocks per §7.

Step 4 — `translate(&DbError, &TranslateContext) → FriendlyError`.
  Classifies by `SqliteErrorKind` first, then by message text
  for the constraint family. `change column` failures route to
  the type-mismatch headline, subsuming the previous
  `friendly_change_column_engine_error` helper.

Step 5 — `DbError::friendly_message()` delegates to the
  translator with default context. Removed
  `friendly_change_column_engine_error` (absorbed) and
  `enrich_fk_message` (FK list moves to the deferred re-query
  step). One test rewritten to assert on the engine-classified
  payload rather than the removed enrichment text.

Step 6 — `messages (short|verbose)` app-level command parallel
  to `mode`. `App::messages_verbosity` (default verbose)
  threaded into `TranslateContext` via
  `App::build_translate_context`. `AppEvent::DslFailed` now
  carries the structured `DbError`, plus the App extracts the
  user's attempted value from `Command::Insert` / `Update`
  to fill the `{value}` placeholder for UNIQUE / NOT NULL.

Step 7 — Catalog validator (§8.6) checks for missing keys,
  unused/undeclared placeholders, format specifiers, and
  forbidden engine vocabulary. `main.rs` parses the embedded
  catalog at startup so a corrupted build artefact fails
  loudly there rather than at the first `t!()` call.

Step 8 — Anchor phrases (§10) held: existing tests asserting
  on "no such table", "already exists", "cannot be converted",
  etc. all pass without rewording.

## Tally

603 tests passing (was 561: +42 net). Clippy clean with
nursery lints. Release binary 7.7 MB.

## Deliberately deferred

- Schema-aware enrichment for FK violations (parent_table /
  parent_column / child_table) and the multi-value
  natural-order INSERT case for UNIQUE. Both need the
  Database handle in scope at translation time, so they
  bundle naturally with the row-pinpoint re-query work
  (ADR-0019 §6) — that follow-on adds runtime-side
  enrichment via a `Database` lookup and a structured
  failure-context carried on `DslFailed`. Until then,
  unfilled placeholders render as their `{name}` form for
  visual consistency with the catalog.
- Migration sweep (§9). Only `error.*` is catalog-driven so
  far; `help.*`, `ok.*`, `client_side.*`, `replay.*`,
  `parse.*`, modal labels, etc. migrate per-PR.
- Settings persistence for `messages`. In-session state for
  now; waits on the future settings ADR.
2026-05-09 12:43:37 +00:00
claude@clouddev1 d4801ea52f ADR-0019: pluralisation is a translator concern, not deferred work
Per follow-up review: §8.5's framing read as "we'll do this
properly later". Reword to make it explicit that real
plural-form rules per locale (Fluent / ICU) are NOT a goal of
this project. Translators handle pluralisation in their
wording (`(s)` shorthand or rephrased templates) — sufficient
for a teaching tool's output surface, and we're not planning
to revisit it.

Matching Out-of-Scope entry tightened the same way.
2026-05-09 08:57:23 +00:00
claude@clouddev1 2a8618c783 ADR-0019: friendly error layer (H1) and i18n message catalog
Settles the design we discussed across this session's
follow-up to the engine-vocabulary audit:

- A central `friendly` module owns translation; the existing
  ad-hoc helpers (`friendly_change_column_engine_error`,
  `enrich_fk_message`) absorb into it.
- Initial catalog covers UNIQUE / FK / NOT NULL / CHECK /
  type-mismatch errors with operation-tailored,
  pedagogically-voiced wording in verbose and short variants.
- New `messages (short|verbose)` app-level command lets
  advanced learners shrink the output. In-session state for
  now; persisted later when settings persistence lands.
- Row pinpointing via post-failure re-query, rendered through
  ADR-0017 §7's bordered diagnostic-table renderer.
  `FriendlyError` is a structured payload (headline + hint +
  optional table); `output_render` composes it.
- i18n foundation: hierarchical YAML catalog, embedded via
  `include_str!`, fixed locale (en-US) for now, no external
  files. `{name}` plain substitution; format specifiers
  explicitly rejected so a translator cannot reformat values.
  Value formats stay invariant across all locales (ISO 8601
  dates, `.` decimals, `true`/`false`, `NULL`) — explicitly
  not a translatable concern.
- Migration sweep is required follow-on but separable: a
  `t!()` macro marks call sites and lets per-category PRs
  land incrementally. Anchor-phrase list (§10) limits test
  churn for the most common substring assertions.

Out of scope and explicitly deferred: advanced-mode SQL
error sanitisation (waits on Q1), settings persistence for
the messages command, plural-form rules per locale, runtime
locale selection, locale-aware value formatting (rejected,
not deferred), constraint-management surface (C3 territory).

README index updated.
2026-05-09 08:49:53 +00:00
claude@clouddev1 c4ee264636 replay: new replay <path> command (A3, U4)
Implements the U4 replay command per handoff §A3:

  replay <path>

Reads <path> and dispatches each non-blank, non-`#`-comment
line through the same DSL pipeline as interactive input.
Aborts at the first per-line failure (parse or runtime),
reporting the line number; previously dispatched commands
stay applied (no rollback) — matches the "I'm replaying my
history" mental model where partial replay is a recoverable
state.

Architecture choices and why:

- **Parsed by the DSL parser** (Command::Replay), not as an
  app-level command alongside `import` / `export`. The
  handoff's implementation sketch was explicit and the
  parsed-AST shape gives us a clean test surface for the
  path-lexing rules. A new `path_literal` parser terminal
  accepts either a single-quoted string (escape rules
  mirror `string_literal` — `''` for a literal quote) or a
  bare run of non-whitespace, with explicit refusal of `'`,
  `(`, `)`, `;` in bare form. Empty paths fail at parse
  time so file-system-layer errors aren't shadowed by
  silly inputs.
- **Routed away from the worker thread.** Command::Replay
  is intercepted in `App::dispatch_dsl` and emitted as
  `Action::Replay` rather than `Action::ExecuteDsl`. Two
  reasons: (1) the worker has no filesystem context, and
  (2) the replay invocation must NOT land in
  `history.log` — otherwise `replay history.log` would
  re-trigger itself recursively. Only the individual
  sub-commands write to history.log via the normal
  per-command persistence path.
- **Inner loop separated from spawn.** `runtime::spawn_replay`
  is a thin tokio::spawn wrapper around `runtime::run_replay`,
  which is `pub` and returns a Vec<AppEvent>. The inner
  function is what tests exercise, sidestepping mpsc plumbing.
- **Relative paths resolve under the project root** so
  `replay history.log` works without ceremony from inside
  any project. Absolute paths pass through unchanged.
- **Nested `replay` is refused.** Allowing `replay foo` from
  inside a replay file invites infinite-loop footguns and
  opens design questions (transitive composition, ordering)
  we'd rather not answer right now. Refusal is explicit.

New plumbing:

- `Command::Replay { path }` AST variant + verb/target_table.
- `Action::Replay { path }` runtime action.
- `AppEvent::ReplayCompleted { path, count }` and
  `AppEvent::ReplayFailed { path, line_number, command, error }`.
- `runtime::run_replay` (public) and `runtime::spawn_replay`.
- App handlers render success as
  `[ok] replay <path> — N command(s) run` and failures as
  `replay <path> failed at line N: <error>` with a
  `  > <command>` echo line for line context. Line 0 is the
  "file open failed" signal — header reads
  `replay <path> failed: <error>` and the echo line is
  suppressed.
- In-app `help` lists the new command with a continuation
  describing comment/blank handling and the relative-path
  rule.

Tests (+20):

- 7 parser tests covering bare/quoted/escaped paths,
  case-insensitive keyword, and refusal cases (no path,
  empty quoted path).
- 9 integration tests in `tests/replay_command.rs`:
  - happy 3-line replay → 3 commands run, state mutated;
  - blank lines + `#` comments skipped;
  - empty file + only-comments file → count 0;
  - missing file → ReplayFailed line_number 0;
  - parse failure mid-replay → reports correct line +
    leaves earlier commands applied + does NOT run later
    lines;
  - runtime failure mid-replay (refers to nonexistent
    table) → reports correct line;
  - nested replay refused;
  - history.log contains per-command entries but NOT the
    `replay …` invocation itself.
- 4 App-level tests: Action::Replay dispatch (not
  ExecuteDsl); ReplayCompleted rendering; ReplayFailed
  rendering with and without line-number context.

541 -> 561 passing, clippy clean with nursery lints,
release build successful.

A future ADR on the parser-as-source-of-truth direction
(handoff §"Pending §3") would bring richer error reporting
for replay parse failures (currently uses the same
single-line wording as interactive parse failures, which is
adequate but not great when a script has many lines around
the failing one).
2026-05-08 15:06:56 +00:00
claude@clouddev1 b8102dc063 tests: ADR-0002 engine-vocabulary audit (A2)
Verifies the user-facing posture in ADR-0002 §"User-facing
posture": no occurrence of SQLite, sqlite, rusqlite, STRICT,
or PRAGMA may appear in any user-reachable string.

The audit's mechanical sweep of `src/` confirmed the
codebase already conforms — every appearance of those
tokens is in either:

- code comments / module-level docstrings (allowed by
  ADR-0002 explicitly),
- DDL strings sent to the engine (not displayed to the
  user),
- internal field/function names like `sqlite_type` /
  `sqlite_strict_type` (code identifiers, not user-visible).

The previous session removed the last known leak in
`do_add_column`. To stop a future change from quietly
re-introducing one, this commit adds a regression test
file covering a representative set of user surfaces:

- `cli::HELP_TEXT` (`--help` banner).
- The in-app `help` command output.
- DSL parse errors for a battery of failing inputs
  (column-name-first typo, unknown type token, mutually
  exclusive flags, missing clause, garbage).
- `DbError::friendly_message()` for realistic Sqlite,
  Unsupported, and InvalidValue payloads — the surface the
  runtime forwards via `AppEvent::DslFailed`.

The forbidden-token list lives in one place
(`engine_vocabulary_audit.rs::FORBIDDEN`) so future audits
can extend it. Failure messages name the leaking token and
its byte offset so a regressing edit pinpoints itself.

Out of scope (and called out in the handoff for separate
work): the H1 friendly-error layer that translates the
remaining engine error wording into pedagogical English.
That needs its own ADR. The local
`friendly_change_column_engine_error` wrapper (db.rs §2354)
is the prototype.

537 -> 541 passing (4 new), clippy clean.
2026-05-08 14:57:12 +00:00
claude@clouddev1 3dbaedc1da help: surface ADR-0017/0018 auto-fill semantics (B1)
ADR-0017 added --force-conversion / --dont-convert as
opt-in flags on `change column`; the help text already
mentioned the flags but didn't explain when they apply.
ADR-0018 generalised serial beyond PK and added auto-fill
on `add column ... (serial|shortid)` for non-empty tables;
none of that was reflected in user-visible help.

This commit:

- Annotates the `add column` line with a continuation note
  that adding serial/shortid to a non-empty table
  auto-fills existing rows.
- Annotates the `change column` line with a continuation
  note that converting to serial/shortid auto-fills null
  cells.
- Appends an "Auto-generated types" section explaining
  serial and shortid: how they auto-fill, that they imply
  UNIQUE outside a PK (serial) or always (shortid), and
  that adding/converting-to either type on a non-empty
  table auto-fills existing/null cells.

The new test
`help_describes_auto_generated_type_behaviour` pins these
phrases so a future help-text edit can't silently drop the
pedagogical lines. The existing
`help_command_lists_supported_commands` and
`help_lists_export_and_import` tests still pass — they
only assert substring presence.

No engine vocabulary leaks (ADR-0002 posture preserved).
536 -> 537 passing, clippy clean.
2026-05-08 14:51:15 +00:00
claude@clouddev1 0d7a7bcd49 db: end-to-end tests for change_column int -> bool (B2)
The (Int, Bool) entry of the ADR-0017 §3 matrix was already
covered at the per-cell unit-test level in `type_change.rs`,
but the end-to-end change_column path through `db.rs` had no
test exercising it. This closes that gap with the two cases
called out in the handoff:

- `change_column_type_int_to_bool_with_zero_one_succeeds`:
  Rows 0/1/0 succeed, no [client-side] note. The matrix
  returns the same Value::Integer for 0 and 1, so
  is_non_identity reports false for every cell and
  ClientSideNote.transformed stays at 0 — the
  `transformed > 0 || auto_filled > 0` filter therefore
  drops the note.
- `change_column_type_int_to_bool_refuses_other_values`:
  Row with 2 → Incompatible. Verified under both Default
  and ForceConversion modes (per ADR-0017 §5: incompatible
  is not lossy, --force-conversion must not advertise).

No production code change; tests only. 534 -> 536 passing,
clippy clean with nursery lints enabled.
2026-05-08 14:49:34 +00:00
claude@clouddev1 dcfeef5d3c Handoff doc for end of 2026-05-08 (#5) 2026-05-08 14:39:54 +00:00
claude@clouddev1 5bb0a147f0 ADR-0018 implementation: auto-fill contracts for serial and shortid
Generalises serial and shortid beyond their previous restricted
forms:

- `serial` is no longer restricted to single-column PK. Non-PK
  serial columns get an emitted UNIQUE constraint and use
  application-side MAX(col)+1 at INSERT time (rowid alias still
  drives the PK case for free; per ADR-0010 worker-thread
  serialisation, the read-then-insert sequence is safe).
- `shortid` columns auto-fill existing null cells when the
  column is materialised — `add column T: x (shortid)` on a
  non-empty table no longer leaves rows in a not-really-valid
  NULL state.
- `int -> serial` joins the type-change matrix as always-clean
  identity (closes the asymmetry vs `text -> shortid`); other
  sources are refused with a route-via-int hint.
- `change column T: x (serial|shortid)` fills null source
  cells with sequence / generated values in the same rebuild
  transaction.

Internal infrastructure:

- ReadColumn gains `unique: bool`; read_schema detects single-
  column UNIQUE indexes via pragma_index_list /
  pragma_index_info; schema_to_ddl emits inline UNIQUE for
  non-PK columns.
- ColumnSchema (persistence) gains `unique: bool` so the flag
  survives YAML round-trip and rebuild-from-text reconstructs
  it faithfully — preserves the "serial -> int leaves UNIQUE
  in place" promise across save/load cycles.
- ChangeColumnTypeResult.client_side now carries `auto_filled`
  + `auto_fill_kind` alongside `transformed` + `lossy`; the
  app handler renders separate note lines when both apply.
- AddColumnResult is a new return type carrying pre-rendered
  [client-side] note lines for the auto-fill paths.

Tests: 519 -> 534 (+15). Clippy clean.
2026-05-08 14:32:19 +00:00
claude@clouddev1 7dfa718c6e parser: structural error rendering, source echo, and caret pointer
The old humanise() fell back to chumsky's terse Display for non-
custom errors and appended "(near `X`)", which on top of an
already-cryptic "found 'i' expected ':'" turned the message into
a puzzle. Now humanise() reads the structured RichReason, lists
expected RichPatterns in plain prose, and prefixes the message
with the consumed context.

Before:  parse error: found 'i' expected ':' (near `i`)
After:   parse error: after `change column Rich`, expected `:`,
         found `i`

dispatch_dsl additionally echoes the source line on parse failure
(matching the success path's "running: ...") and prints a `^` caret
under the failure position, so the user can see what got submitted
and where the parser broke without re-reading from scratch.

Known limit: keyword_ci's custom-error mismatches don't aggregate
across choice alternatives, so messages like "expected DATA or
TABLE" (bison-equivalent) aren't yet possible. That's a structural
fix to the keyword matcher, deferred to a future parser-affordances
ADR.

Tests: +2 structural-error regression tests.
2026-05-08 13:21:39 +00:00
claude@clouddev1 00947b928c ADR-0017 implementation: per-cell type-change with override flags
Replaces the placeholder "trust STRICT" body of do_change_column_type
with the per-cell transformer matrix from ADR-0017. Adds:

- src/type_change.rs: CellOutcome { Clean / Lossy / Incompatible }
  + transform_cell + static_refusal covering every matrix pair
  from §3 (54 unit tests).
- --force-conversion and --dont-convert flags on `change column`
  (mutually exclusive at parse time per §5).
- Refined PK rule (§4.1): refused only when the column has an
  inbound FK and fk_target_type would change. Outbound-FK columns
  still refused outright (§4.2). PK / shortid uniqueness checked
  post-transformation (§4.3).
- Bordered diagnostic tables (lossy / incompatible / collision)
  via the pretty-table renderer (§7) — uses ADR-0016's primitives.
- [client-side] success note (§6) when any cell was rewritten.
- Friendly wrapper for engine-level errors under --dont-convert
  so no engine vocabulary leaks (ADR-0002 user-facing posture).

ADR-0017 §3 + §7 amended in place (with user sign-off): serial->int
added explicitly to the always-clean matrix, and diagnostic rows
identify themselves by PK value(s) rather than positional indices
(SQLite returns rows unordered without ORDER BY, so positional
"row 5" is unaddressable).

Tests: 449 -> 517 (+68). Clippy clean with nursery lints.
2026-05-08 13:21:07 +00:00
claude@clouddev1 545cbf5c0e Handoff doc for end of 2026-05-08 (#4)
Records the session's commits (Iter 5/6 follow-ups, pretty
rendering, B2/C2 column ops, ADR-0016, ADR-0017, ADR-0002
amendment, CLAUDE.md no-engine-name rule), the 449-test
baseline, and the next session's priority: implement
ADR-0017 (per-cell classification, FK-target-type-aware PK
precondition, uniqueness checks for PK + shortid,
--force-conversion / --dont-convert flags, pretty-table
rendered diagnostics).
2026-05-08 10:57:11 +00:00
claude@clouddev1 c3e5f9014f ADR-0017 + ADR-0002 amendment: type-change compatibility + engine-agnostic posture
Specifies the curated per-cell classification (clean /
lossy / incompatible) for column type changes, the static
transformer matrix (numeric chains, text↔structured types,
always-clean stringifications), and the PK / shortid /
uniqueness-bearing handling. Replaces the B2/C2
placeholder of "rely on engine STRICT and surface its
errors" with a learner-friendly model that:

* refuses incompatibles up-front,
* refuses lossy conversions by default with a re-run-with-
  --force-conversion hint,
* refines the PK refusal: an inbound-FK PK is only refused
  when the new type would change the FK target type
  (so `serial → int` and `shortid → text` on FK-referenced
  PKs are allowed; `int → text` etc. still refuse),
* adds a post-transformation uniqueness check for PK and
  shortid columns,
* uses the pretty-table renderer (ADR-0016) for all
  diagnostic row lists,
* emits a `[client-side] …` note in the success summary
  whenever the transformer rewrote any cell.

`--force-conversion` accepts loss; `--dont-convert` skips
the client-side layer entirely; mutually exclusive.

Forward-look: a future iteration may add resolution flags
(`--default 0`, `--on-incompatible '<value>'`).

Also amends ADR-0002 with a new "User-facing posture"
section cementing that the database engine choice is an
implementation detail and is never named in user-visible
strings. Adds a corresponding bullet to CLAUDE.md's
working-style rules so every session picks it up.

Implementation lands as a follow-up.
2026-05-08 10:53:20 +00:00
claude@clouddev1 7b97786ab7 B2/C2: column drop / rename / change-type DSL commands
Closes B2 (rebuild-table reused outside relationships) and
C2 (full add/drop/rename/change-type column operations).

* drop column [from] [table] <T>: <col>
  - ALTER TABLE DROP COLUMN (SQLite 3.35+) + metadata
    cleanup in __rdbms_playground_columns.
  - Refuses PK columns and columns involved in a declared
    relationship (drop the relationship first).

* rename column [in] [table] <T>: <old> to <new>
  - ALTER TABLE RENAME COLUMN (SQLite 3.25+); SQLite
    cascades the rename through FK declarations on other
    tables.
  - Mirrors the new name into both metadata tables
    (__rdbms_playground_columns, __rdbms_playground_relationships)
    so describes stay accurate after a rename.
  - Refuses identity rename and name collisions.

* change column [in] [table] <T>: <col> (<newtype>)
  - Routes through the rebuild_table primitive (ADR-0013)
    since SQLite ALTER doesn't support type changes.
    INSERT INTO new SELECT FROM old; STRICT typing enforces
    cell compatibility, transaction rolls back on mismatch.
  - Refuses PK columns, relationship-involved columns,
    `serial` target, and no-op same-type changes.

Adds 20 tests (parser + db layer); updates the in-app help
listing. Both prepositions independently optional in each
new command, matching `add column`'s grammar shape.

Total: 449 passing, 0 failing, 0 skipped (up from 429).
Clippy clean.

Known spec gap: column-type-change conversion compatibility
is not yet documented (currently relies on SQLite STRICT
errors); follow-up will close this.
2026-05-08 10:09:24 +00:00
claude@clouddev1 41cef5399b parser: make to and table independently optional in add column
Previously the grammar accepted only `to table` together or
neither. The user-stated convention is that bare table
identifiers are accepted in unambiguous positions (matching
how `add 1:n relationship from <T>.<col> to <T>.<col>` takes
bare table names). Both `to` and `table` are now or_not'd
independently, so all four combinations parse identically.

Updates the in-app `help` listing to advertise the new
shape: `add column [to] [table] <T>: <col> (<type>)`.

3 new parser tests cover the variants.
2026-05-08 09:16:50 +00:00
claude@clouddev1 1b27a0c9b1 runtime: suppress silent-rebuild banner for empty projects
A fresh-launch temp project enters run() with no .db, calls
rebuild_from_text on an empty schema, and used to surface a
"[ok] rebuild — 0 tables and 0 rows will be reconstructed"
note that conveyed no information. Now we only emit the note
when the project actually has tables. The explicit `rebuild`
command still always reports its summary — the user asked.
2026-05-08 09:15:19 +00:00
claude@clouddev1 5b5e08d852 ADR-0016 + Iter 5/6 follow-up: pretty table rendering
Replaces the placeholder pipe-and-dash output with Unicode
box-drawing tables for both data results and table-structure
listings, per ADR-0016.

* New `src/output_render.rs` module with `render_data_table`
  and `render_structure`. Hand-rolled to match the project's
  existing CSV/YAML pattern; ~300 lines.
* Header-only outer-frame border style: outer ┌─┐│└─┘ box +
  ├─┤ header underline, no per-row separators. NULL renders
  as `(null)`; cell newlines/tabs/control chars become
  `↵`/`→`/`·` as display-only substitutions.
* Type-aware column alignment: numeric types right-aligned,
  everything else left. `DataResult` gains a `column_types:
  Vec<Option<Type>>` field, populated from the existing
  metadata lookup at the two query sites in db.rs (no new
  query paths).
* Structure view shows Name | Type | Constraints columns;
  References / Referenced-by sections retain plain-text
  format, leaving room for the future relationship-rendering
  ADR.
* 18 new unit tests in output_render.rs (plus 4 insta
  snapshots for the canonical layouts). Existing assertions
  in app.rs and walking_skeleton.rs updated to match the new
  format.

Total: 426 passing, 0 failing, 0 skipped (up from 408).
Clippy clean.
2026-05-08 09:06:02 +00:00
claude@clouddev1 67d68db5f8 Iteration 6: --resume + persistent input history + migration scaffold
Closes out track 2's ADR-0015 backlog.

* `--resume` CLI flag (L1a, ADR-0015 §7) opens the most-
  recently-used project, tracked in <data-root>/last_project.
  Mutually exclusive with a positional <project-path>; errors
  cleanly to stderr (above the shell prompt) on missing file
  or stale recorded path. last_project is rewritten on every
  successful project open (startup, load, new, save as,
  import).
* Persistent input history (I2-persist, ADR-0015 §12). On
  project open, the in-memory navigable history is hydrated
  from the tail of history.log (capped at the in-memory cap).
  ProjectSwitched gains a `history_entries` payload field;
  App::seed_history is the entry point. Pipes inside source
  text round-trip via splitn(3); unknown escape sequences are
  passed through literally.
* Migration framework scaffold (F3, ADR-0015 §9). New
  persistence::migrations module with MigratorRegistry +
  migrate_to_latest + ensure_project_yaml_migrated. Empty
  in v1 (production registry has no migrators); the loader
  runs through it on every project open and is exercised by
  tests with a fake v1→v2 migrator. Writes
  project.yaml.v<N>.bak before any migrator runs; verifies
  each step bumps the version field.

Refreshes docs/requirements.md (A1 / I2 / F3 / E1 / L1a /
test baseline) and adds docs/handoff/20260508-handoff-3.md
covering both Iter 5 and Iter 6.

Total tests: 408 passing, 0 failing, 0 skipped (up from 345
at handoff-2). Clippy clean.
2026-05-08 08:27:50 +00:00
claude@clouddev1 c6cf3df6dc Iteration 5: export / import commands
Implements the `export` and `import` app-level commands per
ADR-0015 §11 + ADR-0007 amendment 1.

- `export [<path>]` writes a zip of project.yaml + data/ to
  <data-root>/YYYYMMDD-<projectname>-export-NN.zip by default,
  preserving the project's directory name as the single
  top-level folder inside the archive.
- `import <zip> [as <target>]` extracts an exported zip into
  a new named project and switches to it. Target name is
  derived from the zip's top-level folder by default; on
  collision the destination auto-suffixes -02, -03, ... up
  to -99 instead of refusing (deviates from §2's refuse-on-
  collision rule for save/save as; recorded as an amendment
  to ADR-0015 §11).
- Excludes playground.db and history.log from the zip.
- Path-traversal protection via zip::enclosed_name + post-
  resolution check that the extraction path stays inside
  the target directory.

Adds the zip = "5" dep with default-features = false +
features = ["deflate"] to keep the binary-size cost modest.

Test baseline: 370 passing, 0 failing, 0 skipped.
2026-05-08 08:24:45 +00:00
claude@clouddev1 ca71184678 Handoff doc + CLAUDE.md and requirements.md refresh
Adds docs/handoff/20260508-handoff-2.md describing the state at
the end of this session: ADR-0015 designed, Iterations 1-4 of
track 2 shipped (file-backed projects with auto-named [temp]
dirs, per-command write-through, rebuild from text on missing
.db, save/save as/new/load/rebuild commands with modal dialogs
and project switching), plus the cleanup pass (--help, in-app
help, post-rebuild message, unmodified-temp cleanup) and the
safety hardening of safely_delete_temp_project. Lists the
next-up moves (Iteration 5: export/import, Iteration 6:
--resume + persistent input history + migration scaffold) and
an end-to-end smoke test.

requirements.md: marks P1-P5, P-NAME-1/2/3, F1, F2, U3, L1 as
[x] with iteration references; adds P-CLEAN-1 for the safe
cleanup; updates A1, I2, H3, L1a progress notes.

CLAUDE.md: updates the project-storage decisions and
deferred-items entry to reflect what's now live vs. still
pending.
2026-05-08 07:07:38 +00:00
claude@clouddev1 58a964da8c Harden temp-project cleanup with stacked safety guards
The previous remove_dir_all on a path returned by Project::path()
was too trusting: an unusual CLI argument or a hand-edited
project.yaml could in principle have steered cleanup into
deleting the wrong directory. Replace it with
safely_delete_temp_project, which refuses unless every one of
the following passes:

1. Path is not a symlink (checked before canonicalize so a
   symlink can't smuggle a different target through).
2. Path is a directory.
3. Canonical path is under <active-data-root>/projects/
   (canonical-prefix containment).
4. Directory basename contains the literal `[temp]` marker.
5. Direct children are exclusively well-known project
   artefacts (project.yaml, data/, history.log,
   playground.db, .gitignore, lock file) plus migration .bak
   files and atomic-write .tmp files. Any stranger file
   (notes.md, .git/, screenshots, etc.) makes the helper
   refuse.

is_unmodified_temp now also requires data/ to be empty, in
addition to project.yaml's tables and relationships being
empty. A hand-edited yaml that drops the schema list but
leaves CSVs in data/ no longer passes.

Failure to delete is non-fatal -- the helper returns
SafeDeleteError, the runtime logs a tracing::warn!, and the
project stays on disk. Leaving an unexpected directory alone
is always preferable to a wrong delete.

Tests: 345 passing (272 lib + 9 + 5 + 6 + 27 + 9 + 17),
0 failing, 0 skipped. 7 new tests covering each guard,
including a unix-only symlink-rejection test.
2026-05-08 06:59:26 +00:00
claude@clouddev1 b7addd6161 Cleanup pass: --help, in-app help, post-rebuild message, unmodified-temp cleanup
Four post-Iteration-4 polish items surfaced by manual testing.

1. `--help` / `-h` CLI flag prints a usage banner (options +
   app-level commands + DSL grammar reference) and exits. Parse
   errors also print the banner to stderr.

2. `help` app-level command notes the same list of supported
   commands to the output panel -- a simple stand-in for the
   richer H3 help system, kept in sync with what's actually
   wired up.

3. The silent rebuild that runs when playground.db is missing
   now surfaces a system message in the output panel ("[ok]
   rebuild -- N tables, M rows reconstructed; ...") via a new
   initial_events plumbing. The user no longer wonders whether
   the .db was magically restored or whether anything happened
   on launch.

4. Unmodified empty temp projects (kind=Temp, project.yaml has
   tables: [] and relationships: []) are now auto-deleted when
   the user switches away (load / new / save as) or quits. This
   addresses the "launch app, load existing project, quit"
   pattern that was leaving an empty temp directory behind
   every time. Modified temps (with any user-created tables or
   relationships) are never auto-deleted; corrupted projects
   are also never auto-deleted (defensive default-to-false on
   yaml read/parse errors).

Tests: 338 passing (272 lib + 9 + 5 + 6 + 20 + 9 + 17),
0 failing, 0 skipped. Clippy clean.
2026-05-08 06:43:49 +00:00
claude@clouddev1 f2198275f0 Iteration 4b: save / save as / new / load with project switching
Adds the rest of the track-2 lifecycle commands (ADR-0015 §11)
and the project-switching machinery they need at runtime.

Temp vs named distinction: replaced the fragile naming heuristic
with an explicit `[temp]` marker in the directory pattern
(`<YYYYMMDD>-[temp]-<word>-<word>-<word>`). validate_user_name
already rejects brackets, so user-typed names can never collide
with a temp marker. The status bar shows `[TEMP] <Display Name>`
for temp projects; the prettifier strips both the date and the
marker so display names are clean.

save / save as: temp project's `save` opens a path-entry modal
(acts as save as); named project's `save` reports "already
auto-saved; use `save as`". `save as` always prompts. Relative
names resolve under <data-root>/projects/; absolute paths used
as-is. Copy excludes the per-process lock file; everything else
(.db, yaml, csvs, history.log) is copied.

new: closes current project, creates a fresh auto-named temp,
switches.

load: opens a picker. List sub-mode shows projects in the active
data root, sorted newest-first by project.yaml mtime; arrow keys
navigate, Enter loads, `b` switches to a path-entry sub-mode for
projects elsewhere, Esc cancels. Empty data root jumps straight
to path entry.

Runtime: `Session` holds Option<Project> + Option<Database> so
project switches can drop old (releasing lock + stopping worker)
before opening new -- required for the "load my own current
project" case. `perform_switch` handles Load / SaveAs / NewTemp
uniformly.

Tests: 332 passing (270 lib + 9 + 5 + 6 + 16 new + 9 + 17),
0 failing, 0 skipped. Clippy clean.
2026-05-08 06:23:46 +00:00
claude@clouddev1 ba93d3c7d8 Iteration 4a: rebuild command with confirmation modal
Adds the explicit `rebuild` app-level command (ADR-0015 §7, §11)
and a modal UI infrastructure to host its confirmation dialog.
Typing `rebuild` emits Action::PrepareRebuild; the runtime reads
project.yaml + data/ to compute a summary ("3 tables and 47 rows
will be reconstructed; the existing playground.db will be
replaced") and posts AppEvent::RebuildPrepared, which opens the
modal. Y confirms, N/Esc cancels. While the modal is open,
normal input is gated.

The worker's do_rebuild_from_text now wipes existing user tables
and metadata before reloading from text, so it works on both
fresh and populated databases. Source text is plumbed through
rebuild_from_text so the explicit rebuild logs to history.log
while the silent on-load rebuild from Iteration 3 stays silent.

Modal infrastructure (App.modal field + key routing + centered
overlay rendering + word-wrap) is reused by Iteration 4b's save
/ save as / load / new flows.

Tests: 314 passing (268 lib + 9 + 5 + 6 new + 9 + 17),
0 failing, 0 skipped. Clippy clean.
2026-05-07 22:27:37 +00:00
claude@clouddev1 f0fc063756 Iteration 3: existence-only load + rebuild from text on missing .db
When the runtime opens a project whose playground.db is missing,
it now rebuilds the database from project.yaml + data/<table>.csv
per ADR-0015 §7. The rebuild path:

1. Parses project.yaml (serde_yml). Unknown versions / types /
   actions surface as PersistenceFatal.
2. Recreates each user table with FK constraints inline
   (PRAGMA foreign_keys=OFF), then populates the column-type,
   relationship, and project metadata tables.
3. Loads each table's CSV via a hand-rolled reader that
   preserves the NULL-vs-empty distinction (the csv crate
   doesn't expose whether a field was quoted; ours does).
4. Runs PRAGMA foreign_key_check before commit; any violation
   aborts.
5. Restores foreign_keys=ON regardless of success.

Row-level failures get DbError::RebuildRowFailed with row
number, file, table, and a friendly per-type detail. They land
in the runtime as a fatal stderr message ("unable to load row N
from `data/T.csv` into table `T`: ...") before the alternate
screen is entered.

created_at from project.yaml overwrites the configure-time
placeholder so timestamps round-trip stably.

Tests: 307 passing (267 lib + 9 + 5 new + 9 + 17), 0 failing,
0 skipped. Clippy clean with nursery lints.
2026-05-07 22:11:45 +00:00
claude@clouddev1 5410075398 Persistence: empty table -> no CSV (per Iteration 2 follow-up)
The Iteration-2 rule wrote a header-only CSV for every existing
table, which surprised users who created a table and saw a file
appear before any data went in. Tighten the rule: a CSV exists
iff the table has rows. Persistence::write_table_data now
delegates to delete_table_data when the snapshot is empty,
removing any prior CSV. The schema-only invariant (YAML knows the
table; CSV knows its rows) is preserved.

The cascade-delete integration test was rewritten to assert the
CSVs vanish; two new tests pin the rule (create -> no CSV;
delete --all-rows -> CSV removed).

Tests: 291 passing (256 lib + 9 + 9 + 17), 0 failing, 0 skipped.
2026-05-07 21:49:28 +00:00