Files
ersatztv/docs/superpowers/plans/2026-07-17-smartcollection-rule-builder.md
T

1294 lines
58 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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/<Domain>/`, namespace `ErsatzTV.Tests.Application.<Domain>`, 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<List<SearchFieldResponseModel>>`; `SearchFieldCatalog.Fields` (static `List<SearchFieldResponseModel>`).
- [ ] **Step 1: (resolved) Test location**
Application handler tests live in `ErsatzTV.Tests/Application/<Domain>/` with namespace `ErsatzTV.Tests.Application.<Domain>`, 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;
/// <summary>
/// One filterable field in the visual rule builder's catalog.
/// <c>Type</c> is one of: text, fulltext, number, date, enum.
/// <c>Values</c> is populated only for <c>enum</c> fields (allowed dropdown values); empty otherwise.
/// </summary>
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<SearchFieldResponseModel> 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<List<SearchFieldResponseModel>>;
```
`ErsatzTV.Application/Search/Queries/GetSearchFieldCatalogHandler.cs`:
```csharp
using ErsatzTV.Core.Api.Search;
using MediatR;
namespace ErsatzTV.Application.Search.Queries;
public class GetSearchFieldCatalogHandler : IRequestHandler<GetSearchFieldCatalog, List<SearchFieldResponseModel>>
{
public Task<List<SearchFieldResponseModel>> 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<SearchFieldResponseModel> 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<SearchFieldResponseModel> 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<SearchFieldResponseModel> 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 24 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<SearchFieldResponseModel>`; 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<SearchFieldResponseModel>), StatusCodes.Status200OK)]
public Task<List<SearchFieldResponseModel>> 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:<port>/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<Rule | Group>;
}
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<FieldType, Operator[]> = {
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<string, FieldType>): 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<string, FieldType> = {
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<string, FieldType>): 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<string, FieldType>, allowNested: boolean): Group | null {
const split = splitTopLevel(input.trim());
if (!split) return null;
const children: Array<Rule | Group> = [];
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<string, FieldType>): 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<string, FieldType> = {
genre: 'text', title: 'text', plot: 'fulltext', type: 'enum', minutes: 'number', release_date: 'date'
};
const BY_TYPE: Record<FieldType, string[]> = {
text: ['genre', 'title'], fulltext: ['plot'], enum: ['type'], number: ['minutes'], date: ['release_date']
};
const OPS: Record<FieldType, Operator[]> = {
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 = <T,>(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<Rule | Group> = [];
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<SearchField[]>` 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<SearchField[]> {
return request<SearchField[]>('/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 `<Nullable>disable</Nullable>`, 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<string, FieldType>;
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<SearchField[]>([]);
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<string, FieldType> = {};
for (const f of fields) fieldTypes[f.name] = f.type;
const groups = new Map<string, RuleField[]>();
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 `<select>` is fine — check `web/src/components` for a `Select` and prefer it if present). Keep it controlled and small.
```tsx
import { Plus, Trash2 } from 'lucide-react';
import { Button, IconButton, Input } from '../../components';
import type { RuleField } from './fieldCatalog';
import { isGroup, OPERATORS_BY_TYPE, type FieldType, type Group, type Operator, type Rule } from './types';
const OP_LABEL: Record<Operator, string> = {
is: 'is', isNot: 'is not', contains: 'contains', startsWith: 'starts with',
matches: 'contains', notMatches: 'does not contain',
eq: '=', gt: '>', lt: '<', between: 'between', before: 'before', after: 'after'
};
function typeOf(fields: RuleField[], name: string): FieldType {
return (fields.find((f) => f.name === name)?.type as FieldType) ?? 'text';
}
function defaultRule(fields: RuleField[]): Rule {
const field = fields[0]?.name ?? 'title';
const type = typeOf(fields, field);
return { field, operator: OPERATORS_BY_TYPE[type][0], value: '' };
}
function RuleRow({ rule, fields, onChange, onRemove }: {
rule: Rule; fields: RuleField[]; onChange: (r: Rule) => void; onRemove: () => void;
}) {
const type = typeOf(fields, rule.field);
const ops = OPERATORS_BY_TYPE[type];
const enumField = fields.find((f) => f.name === rule.field && f.type === 'enum');
return (
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 8 }}>
<select
aria-label="Field"
value={rule.field}
onChange={(e) => {
const nextType = typeOf(fields, e.target.value);
onChange({ field: e.target.value, operator: OPERATORS_BY_TYPE[nextType][0], value: '' });
}}
>
{fields.map((f) => <option key={f.name} value={f.name}>{f.label}</option>)}
</select>
<select aria-label="Operator" value={rule.operator} onChange={(e) => onChange({ ...rule, operator: e.target.value as Operator })}>
{ops.map((op) => <option key={op} value={op}>{OP_LABEL[op]}</option>)}
</select>
{enumField ? (
<select aria-label="Value" value={rule.value} onChange={(e) => onChange({ ...rule, value: e.target.value })}>
<option value=""></option>
{enumField.values.map((v) => <option key={v} value={v}>{v}</option>)}
</select>
) : (
<Input aria-label="Value" value={rule.value} onChange={(e) => onChange({ ...rule, value: e.target.value })}
type={type === 'number' ? 'number' : type === 'date' ? 'date' : 'text'} />
)}
{rule.operator === 'between' && (
<Input aria-label="Upper bound" value={rule.value2 ?? ''} onChange={(e) => onChange({ ...rule, value2: e.target.value })}
type={type === 'number' ? 'number' : 'date'} />
)}
<IconButton aria-label="Remove rule" onClick={onRemove}><Trash2 size={14} /></IconButton>
</div>
);
}
function MatchToggle({ match, onChange }: { match: 'all' | 'any'; onChange: (m: 'all' | 'any') => void }) {
return (
<div style={{ display: 'flex', gap: 6, alignItems: 'center', marginBottom: 8 }}>
<span>Match</span>
<select aria-label="Match" value={match} onChange={(e) => onChange(e.target.value as 'all' | 'any')}>
<option value="all">All</option>
<option value="any">Any</option>
</select>
<span>of the following:</span>
</div>
);
}
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 (
<div style={{ border: depth > 0 ? '1px solid var(--ctv-border, #333)' : 'none', borderRadius: 8, padding: depth > 0 ? 12 : 0, marginBottom: 8 }}>
<MatchToggle match={group.match} onChange={(m) => onChange({ ...group, match: m })} />
{group.children.map((child, i) =>
isGroup(child)
? <GroupEditor key={i} group={child} fields={fields} depth={depth + 1} onChange={(g) => setChild(i, g)} onRemove={() => removeChild(i)} />
: <RuleRow key={i} rule={child} fields={fields} onChange={(r) => setChild(i, r)} onRemove={() => removeChild(i)} />
)}
<div style={{ display: 'flex', gap: 8 }}>
<Button size="sm" variant="secondary" startIcon={<Plus size={14} />}
onClick={() => onChange({ ...group, children: [...group.children, defaultRule(fields)] })}>Add rule</Button>
{depth === 0 && (
<Button size="sm" variant="secondary" startIcon={<Plus size={14} />}
onClick={() => onChange({ ...group, children: [...group.children, { match: 'any', children: [defaultRule(fields)] }] })}>Add group</Button>
)}
{onRemove && <IconButton aria-label="Remove group" onClick={onRemove}><Trash2 size={14} /></IconButton>}
</div>
</div>
);
}
export function RuleBuilder({ value, onChange, fields }: { value: Group; onChange: (g: Group) => void; fields: RuleField[] }) {
return <GroupEditor group={value} fields={fields} depth={0} onChange={onChange} />;
}
```
- [ ] **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(<RuleBuilder value={initial} onChange={onChange} fields={FIELDS} />);
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<Group>(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
<div style={{ display: 'flex', gap: 8, marginTop: 12, marginBottom: 8 }}>
<Button size="sm" variant={mode === 'builder' ? 'primary' : 'secondary'}
disabled={query.trim().length > 0 && parse(query, fieldTypes) === null}
onClick={() => { const p = parse(query, fieldTypes); if (p) { setGroup(p); setQuery(compile(p)); setMode('builder'); } }}>Builder</Button>
<Button size="sm" variant={mode === 'advanced' ? 'primary' : 'secondary'}
onClick={() => { setQuery(compile(group)); setMode('advanced'); }}>Advanced</Button>
</div>
{mode === 'builder' ? (
fieldsStatus === 'success'
? <RuleBuilder value={group} onChange={setGroup} fields={fields} />
: <Spinner />
) : (
<div>
<Input label="Search query" onChange={(event) => 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 && (
<span className="ctv-field-hint">This query is too advanced to show in the builder.</span>
)}
</div>
)}
```
- [ ] **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: <verdict> @ <head-sha>` 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.