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

58 KiB
Raw Blame History

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.csmodify: 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.tsFieldType, Operator, Rule, Group, Match, isGroup.
  • compile.tscompile(group) → string.
  • parse.tsparse(str, fieldTypes) → Group | null.
  • compile.test.ts, parse.test.ts, roundtrip.test.ts.
  • fieldCatalog.tsgetSearchFields() + 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.tsxSmartDialog: 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 usings (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:

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.

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:

using ErsatzTV.Core.Api.Search;
using MediatR;

namespace ErsatzTV.Application.Search.Queries;

public record GetSearchFieldCatalog : IRequest<List<SearchFieldResponseModel>>;

ErsatzTV.Application/Search/Queries/GetSearchFieldCatalogHandler.cs:

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:

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
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/fieldsList<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:

    [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)
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 isfield:"v" · isNotNOT field:"v" · containsfield:*v* · startsWithfield:v*
    • fulltext matchesfield:"v" · notMatchesNOT 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/isNotfield:"v" / NOT field:"v"
    • number eqfield:v · gtfield:{v TO *} · ltfield:{* TO v} · betweenfield:[v TO v2]
    • date beforefield:{* TO v} · afterfield:{v TO *} · betweenfield:[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

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:

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
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
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:

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
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
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.

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
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):

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:

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→[].

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
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.

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:

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
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 onSubmitquery (the compiled/raw Lucene string) remains the single value submitted.

  • Step 1: Add imports at the top of CollectionsScreen.tsx

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:

  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:

  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:

  useEffect(() => {
    if (mode === 'builder') setQuery(compile(group));
  }, [mode, group]);
  • Step 4: Replace the raw Input (lines ~272-280) with the mode toggle + conditional body
      <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:

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
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:

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
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.