# SmartCollection Visual Rule Builder — Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add a Kodi-style visual rule builder to the SmartCollection create/edit dialog that compiles to (and parses back from) a closed subset of the existing Lucene query grammar, with a read-only backend field catalog as the single source of truth. **Architecture:** A new read-only `GET /api/v1/search/fields` returns a curated, typed, labeled field catalog (MediatR query + static list in `ErsatzTV.Application`). The SPA gains pure-TS `compile`/`parse` modules over a closed Lucene subset (exact inverses, proven by a round-trip property test), a `RuleBuilder` React component, and a `Builder | Advanced` mode toggle in the existing `SmartDialog`. Nothing new is persisted — the SmartCollection still stores a plain Lucene query string, so there is **no DB migration and no write-path change**. **Tech Stack:** C# / .NET 10, MediatR (CQRS), ASP.NET Core controllers; React + TypeScript SPA (Vite), vitest + @testing-library/react + jsdom. NUnit + Shouldly for backend tests. ## Global Constraints - **.NET 10**; functional C# with LanguageExt where the surrounding code uses it. - **Backend tests: NUnit + Shouldly + NSubstitute only** — xUnit is not used here. - **No new NuGet or npm packages.** Central Package Management (repo-root `Directory.Packages.props`); the round-trip generator is hand-rolled (do **not** add `fast-check`). - **Every API action needs:** `[ApiController]`, absolute versioned route `[HttpGet("/api/v1/...", Name="...")]`, `[Tags("Search")]`, `[EndpointSummary(...)]`, **`[EndpointGroupName("general")]` (REQUIRED — omitting it drops the endpoint from the OpenAPI doc)**, and `[ProducesResponseType(typeof(X), StatusCodes.Status200OK)]`. - **After any `/api/*` change:** build the app FIRST, then `./scripts/update-openapi.sh`, then `cd web && npm run generate:api`. Regenerated `ErsatzTV/wwwroot/openapi/v1.json`, `docs/endpoint-index.md`, and `web/src/api/generated/v1.d.ts` are committed. - **Docs in the same PR:** tick `docs/api-conventions.md`; append to `docs/decisions.md`; update `docs/spa-conventions.md` for the new component pattern. (No `blazor-route-parity.md` / `domain-model.md` — no route/entity change.) - **Before every push touching `.cs`:** BOM-check the touched set — `for f in $(git diff --name-only origin/main...HEAD -- '*.cs'); do head -c3 "$f" | xxd -p | grep -q "^efbbbf" && echo "BOM: $f"; done` — strip any hit (`charset=utf-8` ⇒ no BOM). - **Worktree:** all work in `.claude/worktrees/176-rule-builder` (branch `feat/176-smartcollection-rule-builder`, off `origin/main`). Copy `web/node_modules` from the shared tree read-only if needed: `cp -R /Users/timothy/ersatztv/web/node_modules web/node_modules`. - **The compiler is the SOLE author of quoting/escaping** so the parser can rely on canonical forms. --- ## File Structure **Backend (new):** - `ErsatzTV.Core/Api/Search/SearchFieldResponseModel.cs` — response DTO (record). - `ErsatzTV.Application/Search/Queries/GetSearchFieldCatalog.cs` — MediatR query (marker record). - `ErsatzTV.Application/Search/Queries/GetSearchFieldCatalogHandler.cs` — returns the curated static catalog. - `ErsatzTV.Application/Search/SearchFieldCatalog.cs` — the curated static list (product-owned, cross-references `LuceneSearchIndex`). - `ErsatzTV/Controllers/Api/SearchController.cs` — **modify**: add the `GET /api/v1/search/fields` action. - `ErsatzTV.Tests/Application/Search/GetSearchFieldCatalogHandlerTests.cs` — NUnit test (new; Application handler tests live under `ErsatzTV.Tests/Application//`, namespace `ErsatzTV.Tests.Application.`, run via `dotnet test ErsatzTV.Tests`). **SPA (new — `web/src/builder/rules/`):** - `types.ts` — `FieldType`, `Operator`, `Rule`, `Group`, `Match`, `isGroup`. - `compile.ts` — `compile(group) → string`. - `parse.ts` — `parse(str, fieldTypes) → Group | null`. - `compile.test.ts`, `parse.test.ts`, `roundtrip.test.ts`. - `fieldCatalog.ts` — `getSearchFields()` + `useSearchFields()` hook. - `RuleBuilder.tsx` — the group/rule UI. - `RuleBuilder.test.tsx`. **SPA (modify):** - `web/src/api/search.ts` — add `getSearchFields` + `SearchField` type. - `web/src/screens/CollectionsScreen.tsx` — `SmartDialog`: `Builder | Advanced` toggle wiring. **Docs (modify):** `docs/api-conventions.md`, `docs/decisions.md`, `docs/spa-conventions.md`. --- ## Task 1: Field catalog — response model, curated list, MediatR handler **Files:** - Create: `ErsatzTV.Core/Api/Search/SearchFieldResponseModel.cs` - Create: `ErsatzTV.Application/Search/SearchFieldCatalog.cs` - Create: `ErsatzTV.Application/Search/Queries/GetSearchFieldCatalog.cs` - Create: `ErsatzTV.Application/Search/Queries/GetSearchFieldCatalogHandler.cs` - Test: `ErsatzTV.Tests/Application/Search/GetSearchFieldCatalogHandlerTests.cs` **Interfaces:** - Produces: `record SearchFieldResponseModel(string Name, string Label, string Type, string Group, string[] Values)`; `record GetSearchFieldCatalog : IRequest>`; `SearchFieldCatalog.Fields` (static `List`). - [ ] **Step 1: (resolved) Test location** Application handler tests live in `ErsatzTV.Tests/Application//` with namespace `ErsatzTV.Tests.Application.`, run via `dotnet test ErsatzTV.Tests`. The new test goes in `ErsatzTV.Tests/Application/Search/`. This repo uses **explicit `using`s** (no ImplicitUsings) — include the `System.*` usings shown in Step 5. Exemplar: `ErsatzTV.Tests/Application/Channels/PreviewAutoTuneChannelsHandlerTests.cs`. - [ ] **Step 2: Write the response model** `ErsatzTV.Core/Api/Search/SearchFieldResponseModel.cs`: ```csharp namespace ErsatzTV.Core.Api.Search; /// /// One filterable field in the visual rule builder's catalog. /// Type is one of: text, fulltext, number, date, enum. /// Values is populated only for enum fields (allowed dropdown values); empty otherwise. /// public record SearchFieldResponseModel(string Name, string Label, string Type, string Group, string[] Values); ``` - [ ] **Step 3: Write the curated catalog** `ErsatzTV.Application/Search/SearchFieldCatalog.cs`. This is a **product-curated** subset of the Lucene index fields (see `ErsatzTV.Infrastructure/Search/LuceneSearchIndex.cs`); internal/index-plumbing fields (`id`, `tag_full`, `library_id`, `library_folder_id`, `jump_letter`, `language_tag`, `sub_language_tag`, `title_and_year_search`, `metadata_kind`) are intentionally omitted. Curation lives here by design, not mirrored mechanically, so Application does not depend on Infrastructure. ```csharp using ErsatzTV.Core.Api.Search; namespace ErsatzTV.Application.Search; public static class SearchFieldCatalog { private static readonly string[] None = []; // Allowed values for the `type` enum — mirror the lowercase tokens the Lucene index stores for // the `type` field. VERIFY against LuceneSearchIndex.cs during implementation and adjust if the // tokens differ; the GetSearchFieldCatalogHandlerTests asserts this list is non-empty. private static readonly string[] ItemTypes = ["movie", "show", "season", "episode", "artist", "music_video", "other_video", "song", "image"]; public static readonly List Fields = [ // General new("title", "Title", "text", "General", None), new("genre", "Genre", "text", "General", None), new("tag", "Tag", "text", "General", None), new("plot", "Plot", "fulltext", "General", None), new("content_rating", "Content rating", "text", "General", None), new("studio", "Studio", "text", "General", None), new("collection", "Collection", "text", "General", None), new("state", "State", "text", "General", None), new("type", "Item type", "enum", "General", ItemTypes), // TV new("network", "Network", "text", "TV", None), new("show_title", "Show title", "text", "TV", None), new("show_genre", "Show genre", "text", "TV", None), new("season_number", "Season number", "number", "TV", None), new("episode_number", "Episode number", "number", "TV", None), // Movie / People new("director", "Director", "text", "Movie", None), new("writer", "Writer", "text", "Movie", None), new("actor", "Actor", "text", "Movie", None), // Music new("artist", "Artist", "text", "Music", None), new("album", "Album", "text", "Music", None), new("album_artist", "Album artist", "text", "Music", None), // Technical new("minutes", "Duration (min)", "number", "Technical", None), new("height", "Height (px)", "number", "Technical", None), new("width", "Width (px)", "number", "Technical", None), new("video_codec", "Video codec", "text", "Technical", None), new("video_dynamic_range", "Dynamic range", "text", "Technical", None), // Dates new("added_date", "Date added", "date", "Dates", None), new("release_date", "Release date", "date", "Dates", None) ]; } ``` - [ ] **Step 4: Write the MediatR query + handler** `ErsatzTV.Application/Search/Queries/GetSearchFieldCatalog.cs`: ```csharp using ErsatzTV.Core.Api.Search; using MediatR; namespace ErsatzTV.Application.Search.Queries; public record GetSearchFieldCatalog : IRequest>; ``` `ErsatzTV.Application/Search/Queries/GetSearchFieldCatalogHandler.cs`: ```csharp using ErsatzTV.Core.Api.Search; using MediatR; namespace ErsatzTV.Application.Search.Queries; public class GetSearchFieldCatalogHandler : IRequestHandler> { public Task> Handle( GetSearchFieldCatalog request, CancellationToken cancellationToken) => Task.FromResult(SearchFieldCatalog.Fields); } ``` - [ ] **Step 5: Write the failing NUnit test** `ErsatzTV.Tests/Application/Search/GetSearchFieldCatalogHandlerTests.cs`: ```csharp using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using ErsatzTV.Application.Search.Queries; using ErsatzTV.Core.Api.Search; using NUnit.Framework; using Shouldly; namespace ErsatzTV.Tests.Application.Search; [TestFixture] public class GetSearchFieldCatalogHandlerTests { [Test] public async Task Returns_Curated_Catalog_Without_Internal_Fields() { var handler = new GetSearchFieldCatalogHandler(); List result = await handler.Handle(new GetSearchFieldCatalog(), CancellationToken.None); result.ShouldNotBeEmpty(); result.Select(f => f.Name).ShouldNotContain("id"); result.Select(f => f.Name).ShouldNotContain("tag_full"); result.Select(f => f.Name).ShouldNotContain("library_folder_id"); } [Test] public async Task Every_Field_Has_A_Known_Type_And_A_Group() { var handler = new GetSearchFieldCatalogHandler(); string[] knownTypes = ["text", "fulltext", "number", "date", "enum"]; List result = await handler.Handle(new GetSearchFieldCatalog(), CancellationToken.None); foreach (SearchFieldResponseModel field in result) { knownTypes.ShouldContain(field.Type); field.Group.ShouldNotBeNullOrWhiteSpace(); field.Label.ShouldNotBeNullOrWhiteSpace(); } } [Test] public async Task Enum_Fields_Carry_NonEmpty_Values_And_NonEnum_Do_Not() { var handler = new GetSearchFieldCatalogHandler(); List result = await handler.Handle(new GetSearchFieldCatalog(), CancellationToken.None); foreach (SearchFieldResponseModel field in result) { if (field.Type == "enum") { field.Values.ShouldNotBeEmpty(); } else { field.Values.ShouldBeEmpty(); } } } } ``` - [ ] **Step 6: Run the test to verify it fails, then passes after building** Run: `cd /Users/timothy/ersatztv/.claude/worktrees/176-rule-builder && dotnet test ErsatzTV.Tests --filter GetSearchFieldCatalogHandlerTests` Expected: fails to compile first if any type is missing, then PASS once Steps 2–4 are in. **Grep the build for `error CS` before trusting a `--no-build` result.** - [ ] **Step 7: Commit** ```bash git add ErsatzTV.Core/Api/Search ErsatzTV.Application/Search ErsatzTV.Tests/Application/Search git -c core.hooksPath=/dev/null commit -m "feat(176): search field catalog query + curated list" ``` --- ## Task 2: Wire the endpoint, regenerate OpenAPI + TS types, tick api-conventions **Files:** - Modify: `ErsatzTV/Controllers/Api/SearchController.cs` - Modify (generated): `ErsatzTV/wwwroot/openapi/v1.json`, `docs/endpoint-index.md`, `web/src/api/generated/v1.d.ts` - Modify: `docs/api-conventions.md` **Interfaces:** - Produces: `GET /api/v1/search/fields` → `List`; generated TS type `components['schemas']['SearchFieldResponseModel']`. - [ ] **Step 1: Add the action to `SearchController`** Add `using ErsatzTV.Application.Search.Queries;` and `using ErsatzTV.Core.Api.Search;` at the top (some may already be present), then this action inside the class: ```csharp [HttpGet("/api/v1/search/fields", Name = "GetSearchFields")] [Tags("Search")] [EndpointSummary("List the filterable fields for the visual rule builder")] [EndpointDescription( "Returns the curated catalog of searchable fields (name, friendly label, type, UI group, and " + "allowed values for enum fields). Drives the SmartCollection rule builder and is introspectable by MCP.")] [EndpointGroupName("general")] [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] public Task> GetSearchFields(CancellationToken cancellationToken) => mediator.Send(new GetSearchFieldCatalog(), cancellationToken); ``` - [ ] **Step 2: Build the app, regenerate the OpenAPI doc + endpoint index** Run: `cd /Users/timothy/ersatztv/.claude/worktrees/176-rule-builder && ./scripts/update-openapi.sh` Expected: builds `ErsatzTV`, regenerates `v1.json` and `docs/endpoint-index.md`. Confirm `GetSearchFields` appears: `grep -n "search/fields" docs/endpoint-index.md`. - [ ] **Step 3: Regenerate the SPA client types** Run: `cd web && npm run generate:api && grep -n "SearchFieldResponseModel" src/api/generated/v1.d.ts` Expected: the schema is present in `v1.d.ts`. - [ ] **Step 4: Tick the api-conventions checklist** In `docs/api-conventions.md`, add `GET /api/v1/search/fields` to the endpoint inventory/exemplar list in the same style as neighboring read-only GET entries (a one-line mention that it returns the rule-builder field catalog). This is the "update the doc in the same PR" requirement for a new endpoint. - [ ] **Step 5: Sanity-run the app and curl the endpoint (optional but recommended)** If a local run is convenient: `dotnet run --project ErsatzTV` then `curl -s localhost:/api/v1/search/fields | jq '.[0]'`. Expected: a JSON object `{ "name": "title", "label": "Title", "type": "text", "group": "General", "values": [] }`. - [ ] **Step 6: Commit (BOM-check first)** ```bash for f in $(git diff --name-only origin/main...HEAD -- '*.cs'); do head -c3 "$f" | xxd -p | grep -q "^efbbbf" && echo "BOM: $f"; done git add ErsatzTV/Controllers/Api/SearchController.cs ErsatzTV/wwwroot/openapi/v1.json docs/endpoint-index.md docs/api-conventions.md web/src/api/generated/v1.d.ts git -c core.hooksPath=/dev/null commit -m "feat(176): GET /api/v1/search/fields endpoint + regenerated api artifacts" ``` --- ## Task 3: TS rule model + compiler (`types.ts`, `compile.ts`) **Files:** - Create: `web/src/builder/rules/types.ts` - Create: `web/src/builder/rules/compile.ts` - Test: `web/src/builder/rules/compile.test.ts` **Interfaces:** - Produces: `FieldType = 'text'|'fulltext'|'number'|'date'|'enum'`; `Operator`; `Rule`; `Group`; `Match`; `isGroup(node)`; `compile(group: Group): string`. - The canonical compiled forms (parser in Task 4 is the exact inverse): - text `is` → `field:"v"` · `isNot` → `NOT field:"v"` · `contains` → `field:*v*` · `startsWith` → `field:v*` - fulltext `matches` → `field:"v"` · `notMatches` → `NOT field:"v"` (distinct operator names from text's `contains`, so `compileRule` maps each operator to exactly one canonical form; parse disambiguates the shared `field:"v"` shape by field type: text/enum→`is`, fulltext→`matches`) - enum `is`/`isNot` → `field:"v"` / `NOT field:"v"` - number `eq` → `field:v` · `gt` → `field:{v TO *}` · `lt` → `field:{* TO v}` · `between` → `field:[v TO v2]` - date `before` → `field:{* TO v}` · `after` → `field:{v TO *}` · `between` → `field:[v TO v2]` - group: children joined by ` AND ` (match `all`) / ` OR ` (match `any`); a **nested** group is wrapped in `( … )`. Top-level group is not wrapped. - [ ] **Step 1: Write `types.ts`** ```ts export type FieldType = 'text' | 'fulltext' | 'number' | 'date' | 'enum'; export type Match = 'all' | 'any'; export type Operator = | 'is' | 'isNot' | 'contains' | 'startsWith' // text | 'matches' | 'notMatches' // fulltext | 'eq' | 'gt' | 'lt' | 'between' // number | 'before' | 'after'; // date export interface Rule { field: string; operator: Operator; value: string; value2?: string; // upper bound for `between` } export interface Group { match: Match; children: Array; } export function isGroup(node: Rule | Group): node is Group { return (node as Group).children !== undefined; } // Which fields each catalog type exposes as operators (used by the UI and tests). export const OPERATORS_BY_TYPE: Record = { text: ['is', 'isNot', 'contains', 'startsWith'], fulltext: ['matches', 'notMatches'], enum: ['is', 'isNot'], number: ['eq', 'gt', 'lt', 'between'], date: ['before', 'after', 'between'] }; ``` - [ ] **Step 2: Write the failing compiler test** `web/src/builder/rules/compile.test.ts`: ```ts import { describe, expect, it } from 'vitest'; import { compile } from './compile'; import type { Group } from './types'; describe('compile', () => { it('quotes text is', () => { const g: Group = { match: 'all', children: [{ field: 'genre', operator: 'is', value: 'Horror' }] }; expect(compile(g)).toBe('genre:"Horror"'); }); it('emits NOT for isNot', () => { const g: Group = { match: 'all', children: [{ field: 'genre', operator: 'isNot', value: 'Horror' }] }; expect(compile(g)).toBe('NOT genre:"Horror"'); }); it('wildcards text contains and startsWith', () => { expect(compile({ match: 'all', children: [{ field: 'title', operator: 'contains', value: 'night' }] })).toBe('title:*night*'); expect(compile({ match: 'all', children: [{ field: 'title', operator: 'startsWith', value: 'The' }] })).toBe('title:The*'); }); it('quotes fulltext matches / notMatches (distinct from text contains)', () => { expect(compile({ match: 'all', children: [{ field: 'plot', operator: 'matches', value: 'car chase' }] })).toBe('plot:"car chase"'); expect(compile({ match: 'all', children: [{ field: 'plot', operator: 'notMatches', value: 'car' }] })).toBe('NOT plot:"car"'); }); it('emits numeric ranges', () => { expect(compile({ match: 'all', children: [{ field: 'minutes', operator: 'gt', value: '30' }] })).toBe('minutes:{30 TO *}'); expect(compile({ match: 'all', children: [{ field: 'minutes', operator: 'lt', value: '90' }] })).toBe('minutes:{* TO 90}'); expect(compile({ match: 'all', children: [{ field: 'minutes', operator: 'between', value: '30', value2: '90' }] })).toBe('minutes:[30 TO 90]'); expect(compile({ match: 'all', children: [{ field: 'minutes', operator: 'eq', value: '42' }] })).toBe('minutes:42'); }); it('emits date ranges', () => { expect(compile({ match: 'all', children: [{ field: 'release_date', operator: 'after', value: '2000-01-01' }] })).toBe('release_date:{2000-01-01 TO *}'); expect(compile({ match: 'all', children: [{ field: 'release_date', operator: 'before', value: '2010-01-01' }] })).toBe('release_date:{* TO 2010-01-01}'); }); it('joins by AND / OR and parenthesizes nested groups', () => { const g: Group = { match: 'all', children: [ { field: 'type', operator: 'is', value: 'movie' }, { match: 'any', children: [ { field: 'genre', operator: 'is', value: 'Horror' }, { field: 'genre', operator: 'is', value: 'Thriller' } ] } ] }; expect(compile(g)).toBe('type:"movie" AND (genre:"Horror" OR genre:"Thriller")'); }); }); ``` - [ ] **Step 3: Run it to verify it fails** Run: `cd web && npx vitest run src/builder/rules/compile.test.ts` Expected: FAIL — `compile` not found. - [ ] **Step 4: Write `compile.ts`** ```ts import { isGroup, type Group, type Rule } from './types'; // The compiler is the sole author of quoting. Quoted phrases escape only " and \. function quote(value: string): string { return `"${value.replace(/(["\\])/g, '\\$1')}"`; } // Wildcard/prefix values escape Lucene specials but keep the wildcard the operator adds. function escapeWild(value: string): string { return value.replace(/([+\-!(){}[\]^"~:\\/])/g, '\\$1'); } function compileRule(rule: Rule): string { const f = rule.field; const v = rule.value; switch (rule.operator) { case 'is': case 'matches': // fulltext: same quoted form as text `is`, disambiguated by field type on parse return `${f}:${quote(v)}`; case 'isNot': case 'notMatches': return `NOT ${f}:${quote(v)}`; case 'contains': return `${f}:*${escapeWild(v)}*`; case 'startsWith': return `${f}:${escapeWild(v)}*`; case 'eq': return `${f}:${v}`; case 'gt': case 'after': return `${f}:{${v} TO *}`; case 'lt': case 'before': return `${f}:{* TO ${v}}`; case 'between': return `${f}:[${v} TO ${rule.value2 ?? ''}]`; default: return ''; } } function compileGroup(group: Group): string { const conn = group.match === 'all' ? ' AND ' : ' OR '; return group.children .map((child) => (isGroup(child) ? `(${compileGroup(child)})` : compileRule(child))) .filter((s) => s.length > 0) .join(conn); } export function compile(group: Group): string { return compileGroup(group); } ``` - [ ] **Step 5: Run the test to verify it passes** Run: `cd web && npx vitest run src/builder/rules/compile.test.ts` Expected: PASS. - [ ] **Step 6: Commit** ```bash git add web/src/builder/rules/types.ts web/src/builder/rules/compile.ts web/src/builder/rules/compile.test.ts git -c core.hooksPath=/dev/null commit -m "feat(176): rule model + Lucene-subset compiler" ``` --- ## Task 4: The parser (`parse.ts`) — exact inverse, null on out-of-subset **Files:** - Create: `web/src/builder/rules/parse.ts` - Test: `web/src/builder/rules/parse.test.ts` **Interfaces:** - Consumes: `types.ts`, the canonical forms from Task 3. - Produces: `parse(input: string, fieldTypes: Record): Group | null`. Returns `null` for any string outside the closed subset (unknown field, mixed AND/OR at one level, nesting deeper than one level, unrecognized clause shape) — this drives the raw-text fallback. A single atom with no connective parses to `{ match: 'all', children: [rule] }`. - [ ] **Step 1: Write the failing parser test** `web/src/builder/rules/parse.test.ts`: ```ts import { describe, expect, it } from 'vitest'; import { parse } from './parse'; import type { FieldType } from './types'; const FIELDS: Record = { genre: 'text', title: 'text', plot: 'fulltext', type: 'enum', minutes: 'number', release_date: 'date' }; describe('parse', () => { it('parses a single quoted text atom as is', () => { expect(parse('genre:"Horror"', FIELDS)).toEqual({ match: 'all', children: [{ field: 'genre', operator: 'is', value: 'Horror' }] }); }); it('parses NOT as isNot / notMatches by field type', () => { expect(parse('NOT genre:"Horror"', FIELDS)).toEqual({ match: 'all', children: [{ field: 'genre', operator: 'isNot', value: 'Horror' }] }); expect(parse('NOT plot:"car"', FIELDS)).toEqual({ match: 'all', children: [{ field: 'plot', operator: 'notMatches', value: 'car' }] }); }); it('parses a quoted fulltext atom as matches', () => { expect(parse('plot:"car"', FIELDS)).toEqual({ match: 'all', children: [{ field: 'plot', operator: 'matches', value: 'car' }] }); }); it('parses wildcard forms', () => { expect(parse('title:*night*', FIELDS)).toEqual({ match: 'all', children: [{ field: 'title', operator: 'contains', value: 'night' }] }); expect(parse('title:The*', FIELDS)).toEqual({ match: 'all', children: [{ field: 'title', operator: 'startsWith', value: 'The' }] }); }); it('parses number/date ranges disambiguated by field type', () => { expect(parse('minutes:{30 TO *}', FIELDS)).toEqual({ match: 'all', children: [{ field: 'minutes', operator: 'gt', value: '30' }] }); expect(parse('release_date:{* TO 2010-01-01}', FIELDS)).toEqual({ match: 'all', children: [{ field: 'release_date', operator: 'before', value: '2010-01-01' }] }); expect(parse('minutes:[30 TO 90]', FIELDS)).toEqual({ match: 'all', children: [{ field: 'minutes', operator: 'between', value: '30', value2: '90' }] }); expect(parse('minutes:42', FIELDS)).toEqual({ match: 'all', children: [{ field: 'minutes', operator: 'eq', value: '42' }] }); }); it('parses a nested group', () => { expect(parse('type:"movie" AND (genre:"Horror" OR genre:"Thriller")', FIELDS)).toEqual({ match: 'all', children: [ { field: 'type', operator: 'is', value: 'movie' }, { match: 'any', children: [ { field: 'genre', operator: 'is', value: 'Horror' }, { field: 'genre', operator: 'is', value: 'Thriller' } ] } ] }); }); it('returns null on out-of-subset input', () => { expect(parse('genre:"a" AND genre:"b" OR genre:"c"', FIELDS)).toBeNull(); // mixed AND/OR at one level expect(parse('unknown_field:"x"', FIELDS)).toBeNull(); // unknown field expect(parse('genre:"a" AND (type:"movie" AND (genre:"b"))', FIELDS)).toBeNull(); // two levels of nesting expect(parse('title:jo~2', FIELDS)).toBeNull(); // fuzzy — not in subset }); }); ``` - [ ] **Step 2: Run it to verify it fails** Run: `cd web && npx vitest run src/builder/rules/parse.test.ts` Expected: FAIL — `parse` not found. - [ ] **Step 3: Write `parse.ts`** ```ts import type { FieldType, Group, Operator, Rule } from './types'; // Split a group body into top-level parts separated by a single connective, respecting quotes and // one level of parens. Returns the parts and the connective, or null if the split is malformed or the // connectives are mixed (mixed AND/OR at one level is out of subset). function splitTopLevel(input: string): { parts: string[]; match: 'all' | 'any' } | null { const parts: string[] = []; const ops: string[] = []; let depth = 0; let quoted = false; let token = ''; for (let i = 0; i < input.length; i++) { const ch = input[i]; if (quoted) { token += ch; if (ch === '"' && input[i - 1] !== '\\') quoted = false; continue; } if (ch === '"') { quoted = true; token += ch; continue; } if (ch === '(') { depth++; token += ch; continue; } if (ch === ')') { depth--; token += ch; continue; } if (depth === 0 && input.startsWith(' AND ', i)) { parts.push(token); ops.push('AND'); token = ''; i += 4; continue; } if (depth === 0 && input.startsWith(' OR ', i)) { parts.push(token); ops.push('OR'); token = ''; i += 3; continue; } token += ch; } if (depth !== 0 || quoted) return null; parts.push(token); if (ops.length === 0) return { parts, match: 'all' }; const allAnd = ops.every((o) => o === 'AND'); const allOr = ops.every((o) => o === 'OR'); if (!allAnd && !allOr) return null; // mixed connectives return { parts, match: allAnd ? 'all' : 'any' }; } function unquote(value: string): string { return value.replace(/\\(["\\])/g, '$1'); } function parseAtom(atom: string, fieldTypes: Record): Rule | null { const trimmed = atom.trim(); const negate = trimmed.startsWith('NOT '); const body = negate ? trimmed.slice(4).trim() : trimmed; const colon = body.indexOf(':'); if (colon < 0) return null; const field = body.slice(0, colon); const raw = body.slice(colon + 1); const type = fieldTypes[field]; if (!type) return null; // Ranges: {a TO b} (exclusive) and [a TO b] (inclusive between) const range = raw.match(/^([[{])(\S+) TO (\S+)([\]}])$/); if (range) { const [, , lo, hi, close] = range; if (type !== 'number' && type !== 'date') return null; if (close === ']') return mk(field, 'between', lo, hi); if (lo === '*') return mk(field, type === 'date' ? 'before' : 'lt', hi); if (hi === '*') return mk(field, type === 'date' ? 'after' : 'gt', lo); return null; } // Quoted phrase → is/isNot (text, enum) or matches/notMatches (fulltext) by field type if (raw.startsWith('"') && raw.endsWith('"') && raw.length >= 2) { const v = unquote(raw.slice(1, -1)); if (type === 'fulltext') return mk(field, negate ? 'notMatches' : 'matches', v); if (type === 'text' || type === 'enum') return mk(field, negate ? 'isNot' : 'is', v); return null; } if (negate) return null; // NOT only valid with quoted forms above // Wildcards (text only): *v* → contains, v* → startsWith if (raw.startsWith('*') && raw.endsWith('*') && raw.length >= 2) { if (type !== 'text') return null; return mk(field, 'contains', unescapeWild(raw.slice(1, -1))); } if (/^[^*]+\*$/.test(raw)) { if (type !== 'text') return null; return mk(field, 'startsWith', unescapeWild(raw.slice(0, -1))); } // Bare token → numeric eq only (reject fuzzy ~, boosts ^, etc.) if (type === 'number' && /^[0-9.]+$/.test(raw)) return mk(field, 'eq', raw); return null; } function unescapeWild(value: string): string { return value.replace(/\\([+\-!(){}[\]^"~:\\/])/g, '$1'); } function mk(field: string, operator: Operator, value: string, value2?: string): Rule { return value2 === undefined ? { field, operator, value } : { field, operator, value, value2 }; } function parseGroup(input: string, fieldTypes: Record, allowNested: boolean): Group | null { const split = splitTopLevel(input.trim()); if (!split) return null; const children: Array = []; for (const part of split.parts) { const p = part.trim(); if (p.startsWith('(') && p.endsWith(')')) { if (!allowNested) return null; // deeper than one level const inner = parseGroup(p.slice(1, -1), fieldTypes, false); if (!inner) return null; children.push(inner); } else { const rule = parseAtom(p, fieldTypes); if (!rule) return null; children.push(rule); } } if (children.length === 0) return null; return { match: split.match, children }; } export function parse(input: string, fieldTypes: Record): Group | null { if (!input.trim()) return null; return parseGroup(input, fieldTypes, true); } ``` - [ ] **Step 4: Run the parser test to verify it passes** Run: `cd web && npx vitest run src/builder/rules/parse.test.ts` Expected: PASS. If the `startsWith` branch misbehaves, note the intent: a value that ends in a single trailing `*` and contains no other `*` is `startsWith`; the regex `/^[^*]+\*$/` captures exactly that. - [ ] **Step 5: Commit** ```bash git add web/src/builder/rules/parse.ts web/src/builder/rules/parse.test.ts git -c core.hooksPath=/dev/null commit -m "feat(176): Lucene-subset parser (exact inverse of compiler)" ``` --- ## Task 5: Round-trip property test (the keystone) **Files:** - Test: `web/src/builder/rules/roundtrip.test.ts` **Interfaces:** - Consumes: `compile`, `parse`, `types.ts`. No new production code — this test is what makes "compile-only, no stored AST" safe. - [ ] **Step 1: Write the generator + property test** `web/src/builder/rules/roundtrip.test.ts`. A deterministic pseudo-random generator (seeded LCG — no `Math.random`, so failures reproduce) builds rule trees within the closed subset; each must satisfy `parse(compile(tree)) deep-equals tree`. Groups always have ≥2 children (a 1-child group's `match` is meaningless and would not round-trip); string values are drawn from an alphabet that INCLUDES Lucene specials, spaces, quotes and backslashes — escaping is total (see the `escapeWild`/`quote` + tokenizer fix, commit `6591c6ef`), so every value must round-trip regardless of content. A generator restricted to `[A-Za-z0-9]` would pass blind to escaping bugs — do not narrow it. ```ts import { describe, expect, it } from 'vitest'; import { compile } from './compile'; import { parse } from './parse'; import type { FieldType, Group, Operator, Rule } from './types'; const FIELDS: Record = { genre: 'text', title: 'text', plot: 'fulltext', type: 'enum', minutes: 'number', release_date: 'date' }; const BY_TYPE: Record = { text: ['genre', 'title'], fulltext: ['plot'], enum: ['type'], number: ['minutes'], date: ['release_date'] }; const OPS: Record = { text: ['is', 'isNot', 'contains', 'startsWith'], fulltext: ['matches', 'notMatches'], enum: ['is', 'isNot'], number: ['eq', 'gt', 'lt', 'between'], date: ['before', 'after', 'between'] }; // Deterministic LCG so a failing case is reproducible. function lcg(seed: number) { let s = seed >>> 0; return () => { s = (1664525 * s + 1013904223) >>> 0; return s / 0xffffffff; }; } const pick = (rng: () => number, arr: T[]): T => arr[Math.floor(rng() * arr.length)]; // Include Lucene specials, spaces, quotes and backslashes: escaping must be TOTAL, so string // values must round-trip regardless of content (this is what catches escaping bugs the pure // alphanumeric generator would miss). Numbers/dates are generated separately in makeRule. const VALUE_CHARS = 'abcdefghijABCDEFGHIJ0123456789 *?"\\():-'; const token = (rng: () => number): string => { const n = 3 + Math.floor(rng() * 5); let out = ''; for (let i = 0; i < n; i++) out += VALUE_CHARS[Math.floor(rng() * VALUE_CHARS.length)]; return out; }; function makeRule(rng: () => number): Rule { const type = pick(rng, Object.keys(BY_TYPE) as FieldType[]); const field = pick(rng, BY_TYPE[type]); const operator = pick(rng, OPS[type]); if (operator === 'between') { return type === 'date' ? { field, operator, value: '2000-01-01', value2: '2010-01-01' } : { field, operator, value: '10', value2: '90' }; } if (type === 'number') return { field, operator, value: String(1 + Math.floor(rng() * 500)) }; if (type === 'date') return { field, operator, value: '2005-06-15' }; return { field, operator, value: token(rng) }; } function makeGroup(rng: () => number, allowNested: boolean): Group { const match = rng() < 0.5 ? 'all' : 'any'; const count = 2 + Math.floor(rng() * 3); // >= 2 children const children: Array = []; for (let i = 0; i < count; i++) { if (allowNested && rng() < 0.25) children.push(makeGroup(rng, false)); else children.push(makeRule(rng)); } return { match, children }; } describe('round-trip: parse(compile(tree)) === tree', () => { it('holds over 500 generated trees', () => { const rng = lcg(12345); for (let i = 0; i < 500; i++) { const tree = makeGroup(rng, true); const text = compile(tree); const back = parse(text, FIELDS); expect(back, `seed-iter ${i} failed for: ${text}`).toEqual(tree); } }); }); ``` - [ ] **Step 2: Run it** Run: `cd web && npx vitest run src/builder/rules/roundtrip.test.ts` Expected: PASS. If a case fails, the assertion message prints the exact compiled string — fix `compile`/`parse` to be exact inverses for that shape (do NOT weaken the generator to hide it, unless the shape is genuinely out-of-subset, in which case document why). - [ ] **Step 3: Commit** ```bash git add web/src/builder/rules/roundtrip.test.ts git -c core.hooksPath=/dev/null commit -m "test(176): compile/parse round-trip property test" ``` --- ## Task 6: Field catalog client (`fieldCatalog.ts` + api) **Files:** - Modify: `web/src/api/search.ts` - Create: `web/src/builder/rules/fieldCatalog.ts` - Test: `web/src/api/search.test.ts` (add a case), `web/src/builder/rules/fieldCatalog.test.ts` **Interfaces:** - Produces: `getSearchFields(): Promise` and `type SearchField`; `useSearchFields()` → `{ fields, fieldTypes, byGroup, status }`. - [ ] **Step 1: Add `getSearchFields` to `web/src/api/search.ts`** Add near the top (after the existing type exports): ```ts export type SearchField = components['schemas']['SearchFieldResponseModel']; export function getSearchFields(): Promise { return request('/api/v1/search/fields'); } ``` - [ ] **Step 2: Add an api test (`web/src/api/search.test.ts`)** Follow the existing tests in that file (they mock `request`/`fetch`). Add: ```ts it('getSearchFields calls the catalog endpoint', async () => { // Match the mocking style already used in this file (fetch or ./client mock). const result = await getSearchFields(); expect(Array.isArray(result)).toBe(true); }); ``` Adapt to the file's actual mock harness — read the top of `search.test.ts` first and mirror it (do not invent a new mocking approach). - [ ] **Step 3: Write `fieldCatalog.ts` (hook)** The generated `SearchField` type is **all-nullable** (`ErsatzTV.Core` compiles with `disable`, so every response-model string is `string | null` in `v1.d.ts`). To keep that null-noise out of the UI (Tasks 7/8), the hook coerces to a clean **`RuleField`** (all non-null) — drop entries with no `name`, default `type→'text'`, `group→'Other'`, `label→name`, `values→[]`. ```ts import { useEffect, useState } from 'react'; import { getSearchFields, type SearchField } from '../../api/search'; import type { FieldType } from './types'; // Clean, non-null field the builder UI consumes (SearchField from the generated client is all-nullable). export interface RuleField { name: string; label: string; type: FieldType; group: string; values: string[]; } export interface FieldCatalog { fields: RuleField[]; fieldTypes: Record; byGroup: Array<{ group: string; fields: RuleField[] }>; status: 'loading' | 'success' | 'error'; } function toRuleField(f: SearchField): RuleField | null { if (!f.name) return null; return { name: f.name, label: f.label ?? f.name, type: (f.type as FieldType | null) ?? 'text', group: f.group ?? 'Other', values: f.values ?? [] }; } export function useSearchFields(): FieldCatalog { const [raw, setRaw] = useState([]); const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading'); useEffect(() => { let active = true; getSearchFields() .then((f) => { if (active) { setRaw(f); setStatus('success'); } }) .catch(() => { if (active) setStatus('error'); }); return () => { active = false; }; }, []); const fields = raw.map(toRuleField).filter((f): f is RuleField => f !== null); const fieldTypes: Record = {}; for (const f of fields) fieldTypes[f.name] = f.type; const groups = new Map(); for (const f of fields) { const list = groups.get(f.group) ?? []; list.push(f); groups.set(f.group, list); } const byGroup = [...groups.entries()].map(([group, gf]) => ({ group, fields: gf })); return { fields, fieldTypes, byGroup, status }; } ``` - [ ] **Step 4: Run tests + typecheck** Run: `cd web && npx vitest run src/api/search.test.ts && npx tsc -b --pretty false` Expected: PASS, no type errors. - [ ] **Step 5: Commit** ```bash git add web/src/api/search.ts web/src/api/search.test.ts web/src/builder/rules/fieldCatalog.ts git -c core.hooksPath=/dev/null commit -m "feat(176): field-catalog api + useSearchFields hook" ``` --- ## Task 7: `RuleBuilder.tsx` component **Files:** - Create: `web/src/builder/rules/RuleBuilder.tsx` - Test: `web/src/builder/rules/RuleBuilder.test.tsx` **Interfaces:** - Consumes: `types.ts` (`Group`, `Rule`, `Operator`, `OPERATORS_BY_TYPE`), `RuleField` (from `./fieldCatalog` — clean, non-null). - Produces: `RuleBuilder({ value, onChange, fields }: { value: Group; onChange: (g: Group) => void; fields: RuleField[] })`. Renders the top group's `Match: All | Any` toggle, a row per child (`field ▾` → `operator ▾` → value input by type; `between` shows two inputs; `enum` shows a value dropdown), `+ Add rule` / `+ Add group` (one nested level), and remove buttons. Purely controlled — every edit calls `onChange` with the next `Group`. - [ ] **Step 1: Write the component** Use the existing SPA primitives (`../../components` exports `Button`, `IconButton`, `Input`, `Switch`; native ` { const nextType = typeOf(fields, e.target.value); onChange({ field: e.target.value, operator: OPERATORS_BY_TYPE[nextType][0], value: '' }); }} > {fields.map((f) => )} {enumField ? ( ) : ( onChange({ ...rule, value: e.target.value })} type={type === 'number' ? 'number' : type === 'date' ? 'date' : 'text'} /> )} {rule.operator === 'between' && ( onChange({ ...rule, value2: e.target.value })} type={type === 'number' ? 'number' : 'date'} /> )} ); } function MatchToggle({ match, onChange }: { match: 'all' | 'any'; onChange: (m: 'all' | 'any') => void }) { return (
Match of the following:
); } function GroupEditor({ group, fields, depth, onChange, onRemove }: { group: Group; fields: RuleField[]; depth: number; onChange: (g: Group) => void; onRemove?: () => void; }) { const setChild = (i: number, child: Rule | Group) => { const children = group.children.slice(); children[i] = child; onChange({ ...group, children }); }; const removeChild = (i: number) => onChange({ ...group, children: group.children.filter((_, j) => j !== i) }); return (
0 ? '1px solid var(--ctv-border, #333)' : 'none', borderRadius: 8, padding: depth > 0 ? 12 : 0, marginBottom: 8 }}> onChange({ ...group, match: m })} /> {group.children.map((child, i) => isGroup(child) ? setChild(i, g)} onRemove={() => removeChild(i)} /> : setChild(i, r)} onRemove={() => removeChild(i)} /> )}
{depth === 0 && ( )} {onRemove && }
); } export function RuleBuilder({ value, onChange, fields }: { value: Group; onChange: (g: Group) => void; fields: RuleField[] }) { return ; } ``` - [ ] **Step 2: Write the component test** `web/src/builder/rules/RuleBuilder.test.tsx`: ```tsx import { render, screen, fireEvent } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import { RuleBuilder } from './RuleBuilder'; import type { RuleField } from './fieldCatalog'; import type { Group } from './types'; const FIELDS: RuleField[] = [ { name: 'genre', label: 'Genre', type: 'text', group: 'General', values: [] }, { name: 'type', label: 'Item type', type: 'enum', group: 'General', values: ['movie', 'episode'] } ]; function setup(initial: Group) { const onChange = vi.fn(); const utils = render(); return { onChange, ...utils }; } describe('RuleBuilder', () => { it('adds a rule', () => { const { onChange } = setup({ match: 'all', children: [] }); fireEvent.click(screen.getByText('Add rule')); expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ match: 'all', children: expect.arrayContaining([expect.objectContaining({ field: 'genre' })]) })); }); it('removes a rule', () => { const { onChange } = setup({ match: 'all', children: [{ field: 'genre', operator: 'is', value: 'Horror' }] }); fireEvent.click(screen.getByLabelText('Remove rule')); expect(onChange).toHaveBeenCalledWith({ match: 'all', children: [] }); }); it('adds a nested group only at top level', () => { const { onChange } = setup({ match: 'all', children: [] }); fireEvent.click(screen.getByText('Add group')); expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ children: expect.arrayContaining([expect.objectContaining({ match: 'any' })]) })); }); it('shows enum values as a dropdown', () => { setup({ match: 'all', children: [{ field: 'type', operator: 'is', value: 'movie' }] }); expect(screen.getByRole('option', { name: 'episode' })).toBeInTheDocument(); }); }); ``` - [ ] **Step 3: Run the tests + typecheck** Run: `cd web && npx vitest run src/builder/rules/RuleBuilder.test.tsx && npx tsc -b --pretty false` Expected: PASS. (If `IconButton`/`Button`/`Input` prop names differ, read `web/src/components/index.ts` and adjust — mirror how `CollectionsScreen.tsx` uses them.) - [ ] **Step 4: Commit** ```bash git add web/src/builder/rules/RuleBuilder.tsx web/src/builder/rules/RuleBuilder.test.tsx git -c core.hooksPath=/dev/null commit -m "feat(176): RuleBuilder component" ``` --- ## Task 8: Integrate into `SmartDialog` (Builder | Advanced toggle) **Files:** - Modify: `web/src/screens/CollectionsScreen.tsx` (the `SmartDialog` function, ~lines 204-300) - Test: extend `web/src/screens/CollectionsScreen.test.tsx` if it exists; otherwise add a focused `SmartDialog` test file. **Interfaces:** - Consumes: `RuleBuilder`, `useSearchFields`, `compile`, `parse`. No change to `SmartDialog`'s props or `onSubmit` — `query` (the compiled/raw Lucene string) remains the single value submitted. - [ ] **Step 1: Add imports at the top of `CollectionsScreen.tsx`** ```ts import { RuleBuilder } from '../builder/rules/RuleBuilder'; import { useSearchFields } from '../builder/rules/fieldCatalog'; import { compile } from '../builder/rules/compile'; import { parse } from '../builder/rules/parse'; import type { Group } from '../builder/rules/types'; ``` - [ ] **Step 2: Add builder state in `SmartDialog` and derive the initial mode from the seeded query** Inside `SmartDialog`, after the existing `query` state (around line 221), add: ```ts const { fields, fieldTypes, status: fieldsStatus } = useSearchFields(); const seededGroup = parse(initial?.query ?? '', fieldTypes); const [mode, setMode] = useState<'builder' | 'advanced'>(() => initial?.query && !parse(initial.query, fieldTypes) ? 'advanced' : 'builder' ); const [group, setGroup] = useState(seededGroup ?? { match: 'all', children: [] }); ``` Note: `fieldTypes` is empty on first render (catalog still loading), so `parse` returns `null` and the dialog opens in `advanced` until the catalog arrives. To seed the builder once fields load, add: ```ts useEffect(() => { if (fieldsStatus !== 'success' || !initial?.query) return; const parsed = parse(initial.query, fieldTypes); if (parsed) { setGroup(parsed); setQuery(compile(parsed)); setMode('builder'); } else { setMode('advanced'); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [fieldsStatus]); ``` - [ ] **Step 3: Keep `query` in sync when in builder mode** Add an effect so the compiled string is what gets submitted/previewed: ```ts useEffect(() => { if (mode === 'builder') setQuery(compile(group)); }, [mode, group]); ``` - [ ] **Step 4: Replace the raw `Input` (lines ~272-280) with the mode toggle + conditional body** ```tsx
{mode === 'builder' ? ( fieldsStatus === 'success' ? : ) : (
setQuery(event.target.value)} placeholder='e.g. genre:"action" AND type:"movie"' style={{ fontFamily: 'var(--font-mono)' }} value={query} /> {parse(query, fieldTypes) === null && query.trim().length > 0 && ( This query is too advanced to show in the builder. )}
)} ``` - [ ] **Step 5: Add a focused test** Read `CollectionsScreen.test.tsx` (if present) for the existing harness; otherwise create `web/src/screens/CollectionsScreen.smartDialog.test.tsx` that mocks `../builder/rules/fieldCatalog` (`useSearchFields` → success with a small catalog) and `../api` (so `getLibraryBrowseItems` is stubbed). Assert: ```tsx it('opens an existing subset query in builder mode', () => { // render the CollectionsScreen with a smart collection whose query is 'genre:"Horror"' // then: expect(screen.getByLabelText('Field')).toHaveValue('genre'); }); it('falls back to advanced mode for an out-of-subset query', () => { // query 'genre:jo~2' → expect the raw Search query input to be visible }); ``` Fill these in against the real screen harness (the exact render/props mirror the existing CollectionsScreen tests). If wiring a full-screen render is heavy, extract `SmartDialog` is **not** required — test via the screen as the existing tests do. - [ ] **Step 6: Run web tests, typecheck, lint** Run: `cd web && npx vitest run && npx tsc -b --pretty false && npm run lint` Expected: all PASS. Give any heavy-render test an explicit vitest timeout (e.g. `it('…', { timeout: 15000 }, …)`) — the CI VM is slower than local. - [ ] **Step 7: Commit** ```bash git add web/src/screens/CollectionsScreen.tsx web/src/screens/CollectionsScreen*.test.tsx git -c core.hooksPath=/dev/null commit -m "feat(176): Builder|Advanced toggle in the SmartCollection dialog" ``` --- ## Task 9: Docs, full verification, live smoke, PR **Files:** - Modify: `docs/decisions.md`, `docs/spa-conventions.md` - [ ] **Step 1: Append the decision to `docs/decisions.md`** Add a dated entry (append-only) recording: the compile-only / closed-subset / **no-stored-AST** model for the SmartCollection rule builder (why: even an authoritative AST would still need a Lucene→rules parser for pre-existing free-text queries, so persisting an AST buys almost nothing while costing a dual-provider migration); the **one-level-nesting Kodi model**; and the read-only `GET /api/v1/search/fields` catalog as the field source of truth. Note the deferred follow-ups. - [ ] **Step 2: Document the reusable component pattern in `docs/spa-conventions.md`** Add a short subsection: the `web/src/builder/rules/` module (types + compile/parse + `useSearchFields` + `RuleBuilder`) is a reusable, controlled component that compiles to the Lucene query string; screens embed it and own the `query` string. Note it is intended for later reuse by ChannelBuilder / Auto-Tune. - [ ] **Step 3: Full local verification** Run: ```bash cd /Users/timothy/ersatztv/.claude/worktrees/176-rule-builder dotnet build ErsatzTV.sln 2>&1 | grep -E "error|Build succeeded" dotnet test ErsatzTV.Tests --filter GetSearchFieldCatalogHandlerTests cd web && npx vitest run && npx tsc -b --pretty false && npm run lint && npm run check:api ``` Expected: `Build succeeded`, all tests PASS, `check:api` clean (no uncommitted generated diff). - [ ] **Step 4: Live smoke (Playwright, headless)** Per `docs/e2e-local.md`, bring up a local instance with a seeded library, then drive: open Collections → Smart tab → New → Builder mode → add `genre is Horror` → confirm the live preview count updates → Save → reopen → confirm it opens in Builder with the rule populated. (Live-E2E is **not gated** here — read-only endpoint, unchanged write path — but this smoke is cheap insurance.) Never open download endpoints in a browser tab. - [ ] **Step 5: BOM-check, push, open PR** ```bash for f in $(git diff --name-only origin/main...HEAD -- '*.cs'); do head -c3 "$f" | xxd -p | grep -q "^efbbbf" && echo "BOM: $f"; done # rebase on origin/main if it moved: git fetch origin main && git rebase origin/main (regenerate v1.json/v1.d.ts/endpoint-index if they conflict) git push -u origin feat/176-smartcollection-rule-builder ``` Open a PR with `fixes #176` (or `part of #176` if you split the deferred follow-ups). Arm a CI monitor on the head sha at PR-open. - [ ] **Step 6: Cold-context adversarial review + follow-up issues** Dispatch a cold, review-only agent (cross-model if available) scoped to the diff — focus on the parser/compiler round-trip correctness and the escape/quote handling. Post a `Review-verdict: @ ` PR comment. File the deferred follow-ups as separate issues: facet typeahead, relative dates, deeper nesting, ChannelBuilder/Auto-Tune inline adoption. Run the H12 qualification audit and label anything new. --- ## Self-Review (checked against the spec) **Spec coverage:** §3 rule model → Task 3 (`types.ts`) + Tasks 7-8; §3 closed subset compile → Task 3; parse/round-trip → Tasks 4-5; §4 catalog endpoint → Tasks 1-2; §5 UI integration + mode switching + open-existing behavior → Task 8; §6 testing (property, compile/parse, component, backend NUnit, live smoke, cold review) → Tasks 1,3,4,5,7,8,9; §7 docs → Tasks 2,9; §8 deferred follow-ups → Task 9 Step 6. No spec section is unmapped. **Type consistency:** `Group`/`Rule`/`Operator`/`Match`/`FieldType`/`isGroup` defined in Task 3 and consumed unchanged in Tasks 4-8; `compile(group)`/`parse(input, fieldTypes)` signatures identical everywhere; `SearchFieldResponseModel(Name,Label,Type,Group,Values)` (C#) ↔ `SearchField` (TS generated) fields align; `useSearchFields()` returns `{ fields, fieldTypes, byGroup, status }` and every consumer uses those names. **Placeholder scan:** the two intentionally implementer-adapted spots (the exact `*.Tests` project name in Task 1 Step 1; mirroring the existing api/screen test harness in Tasks 6 & 8) are gated by an explicit "read the sibling file first" instruction, not vague TODOs. The `type` enum token list carries an explicit VERIFY-against-`LuceneSearchIndex` step backed by a test asserting non-emptiness.