package search import ( "context" "fmt" "os" "path/filepath" "strings" "sync" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "sourcecraft.dev/bigbes/sr-ht-spec/core" "sourcecraft.dev/bigbes/sr-ht-spec/doc" ) // Ported from warren: an empty or whitespace-only query returns nothing rather // than everything. func TestSearchIgnoresWhitespaceOnlyQuery(t *testing.T) { idx := indexCorpus(t, newCorpus(t, "~bigbes/specs", "rev1"). add("specs/storage.md", "---\ntitle: Needle handbook\n---\n\nreference material\n")) for _, q := range []string{"", " \t\n "} { res, err := idx.Search(context.Background(), Query{Text: q, Spaces: core.EverythingFilter()}) require.NoError(t, err) require.Empty(t, res.Hits) require.Zero(t, res.Total) } } // Ported from warren's keyword fixture: a title match outranks a body match, so // a query that names a document returns the document. func TestTitleMatchOutranksBodyMatch(t *testing.T) { idx := indexCorpus(t, newCorpus(t, "~bigbes/specs", "rev1"). add("specs/handbook.md", "---\nid: SPEC-0001\ntitle: Needle handbook\n---\n\nreference material\n"). add("specs/other.md", "---\nid: SPEC-0002\ntitle: Other document\n---\n\na needle appears in the body\n")) require.Equal(t, []string{"SPEC-0001", "SPEC-0002"}, hitIDs(t, idx, Query{Text: "needle", Spaces: core.EverythingFilter()})) } // A project is a saved filter over the one global index. This is the whole of // it: the same index, queried with a space set. func TestProjectIsASpaceFilterOverOneIndex(t *testing.T) { rfcs := newCorpus(t, "~bigbes/rfcs", "rev1"). add("specs/storage.md", "---\nid: SPEC-0001\ntitle: Storage model\n---\n\nA shared vocabulary term.\n") ops := newCorpus(t, "~bigbes/home-ops", "rev1"). add("notes/hosts.md", "---\nid: NOTE-0001\ntitle: Hosts\n---\n\nAnother shared vocabulary term.\n") other := newCorpus(t, "~someone/private", "rev1"). add("specs/x.md", "---\nid: SPEC-0009\ntitle: Elsewhere\n---\n\nA third shared vocabulary term.\n") idx := indexCorpus(t, rfcs, ops, other) // The meta-project: a filter that excludes nothing. all := hitIDs(t, idx, Query{Text: "vocabulary", Spaces: core.EverythingFilter()}) require.ElementsMatch(t, []string{"SPEC-0001", "NOTE-0001", "SPEC-0009"}, all) // A project over two of the three spaces. project := hitIDs(t, idx, Query{Text: "vocabulary", Spaces: core.SpacesFilter([]core.SpaceRef{rfcs.Space, ops.Space}, nil)}) require.ElementsMatch(t, []string{"SPEC-0001", "NOTE-0001"}, project) // One space. require.Equal(t, []string{"SPEC-0009"}, hitIDs(t, idx, Query{Text: "vocabulary", Spaces: core.SpacesFilter([]core.SpaceRef{other.Space}, nil)})) } // Space names are matched whole. Filtering through an analyzed field — which is // what warren did for sections — would tokenize "~bigbes/home-ops" and let a // query for one space return another. func TestSpaceFilterMatchesWholeNamesOnly(t *testing.T) { ops := newCorpus(t, "~bigbes/home-ops", "rev1"). add("notes/a.md", "---\nid: NOTE-0001\ntitle: A\n---\n\nshared vocabulary\n") home := newCorpus(t, "~bigbes/home", "rev1"). add("notes/b.md", "---\nid: NOTE-0002\ntitle: B\n---\n\nshared vocabulary\n") idx := indexCorpus(t, ops, home) require.Equal(t, []string{"NOTE-0002"}, hitIDs(t, idx, Query{Text: "vocabulary", Spaces: core.SpacesFilter([]core.SpaceRef{home.Space}, nil)})) } // The whole reason Query.Spaces is a filter and not a []core.SpaceRef: an // empty project selects nothing, and "nothing" must never widen to "the entire // corpus" on the way into a query. With a slice it did — no terms read as no // restriction — and every test written with a non-empty project passed anyway. func TestEmptyProjectFindsNothingRatherThanEverything(t *testing.T) { rfcs := newCorpus(t, "~bigbes/rfcs", "rev1"). add("specs/storage.md", "---\nid: SPEC-0001\ntitle: Storage model\n---\n\nA shared vocabulary term.\n") ops := newCorpus(t, "~bigbes/home-ops", "rev1"). add("notes/hosts.md", "---\nid: NOTE-0001\ntitle: Hosts\n---\n\nAnother shared vocabulary term.\n") idx := indexCorpus(t, rfcs, ops) // What service.ResolveProject returns for a project nobody has added a // space to yet. empty := core.SpacesFilter(nil, nil) require.True(t, empty.MatchesNothing()) require.False(t, empty.Everything()) res, err := idx.Search(context.Background(), Query{Text: "vocabulary", Spaces: empty}) require.NoError(t, err) require.Empty(t, res.Hits, "an empty project must return no hits, not the corpus") require.Zero(t, res.Total) // The same query over the meta-project, to show the corpus was there to be // returned and the filter is what withheld it. require.Len(t, hitIDs(t, idx, Query{Text: "vocabulary", Spaces: core.EverythingFilter()}), 2) } // A scope nobody set is neither answer, and is refused rather than defaulted: // both plausible defaults are wrong for one of the two callers that could // produce it. func TestSearchRefusesAQueryWithNoScope(t *testing.T) { idx := indexCorpus(t, newCorpus(t, "~bigbes/specs", "rev1"). add("a.md", "---\ntitle: A\n---\n\nbody text here\n")) _, err := idx.Search(context.Background(), Query{Text: "body"}) require.ErrorContains(t, err, "no space scope") } func TestSearchRejectsAnEmptySpaceFilter(t *testing.T) { idx := indexCorpus(t, newCorpus(t, "~bigbes/specs", "rev1"). add("a.md", "---\ntitle: A\n---\n\nbody text here\n")) _, err := idx.Search(context.Background(), Query{Text: "body", Spaces: core.SpacesFilter([]core.SpaceRef{{}}, nil)}) require.ErrorContains(t, err, "empty space") } // Ported from warren: log entries summarise other documents, so they are out of // an unrestricted search and reachable by naming the section. func TestLogEntriesAreExcludedUntilAskedFor(t *testing.T) { idx := indexCorpus(t, newCorpus(t, "~bigbes/specs", "rev1"). add("specs/storage.md", "---\nid: SPEC-0001\ntitle: Storage model\n---\n\nThe write path resolves a tree.\n"). add("log.md", "# Log\n\n## [2026-05-31] update | Storage model\nRewrote the write path section.\n")) require.Equal(t, []string{"SPEC-0001"}, hitIDs(t, idx, Query{Text: "write path", Spaces: core.EverythingFilter()})) // Naming the section is the way back in. The log document itself carries no // body, so only its entry matches the text. require.Equal(t, []string{"log#2026-05-31-1"}, hitIDs(t, idx, Query{Text: "write path", Sections: []string{doc.LogSection}, Spaces: core.EverythingFilter()})) require.Equal(t, []string{"log"}, hitIDs(t, idx, Query{Text: "Log", Sections: []string{doc.LogSection}, Spaces: core.EverythingFilter()}), "the log document stays findable by name") } func TestSectionFilterRestrictsResults(t *testing.T) { idx := indexCorpus(t, newCorpus(t, "~bigbes/specs", "rev1"). add("specs/storage.md", "---\nid: SPEC-0001\ntitle: Storage\n---\n\nshared vocabulary term\n"). add("notes/scratch.md", "---\nid: NOTE-0001\ntitle: Scratch\n---\n\nshared vocabulary term\n")) require.Equal(t, []string{"SPEC-0001"}, hitIDs(t, idx, Query{Text: "vocabulary", Sections: []string{"specs"}, Spaces: core.EverythingFilter()})) require.Equal(t, []string{"NOTE-0001"}, hitIDs(t, idx, Query{Text: "vocabulary", Sections: []string{"notes"}, Spaces: core.EverythingFilter()})) require.Len(t, hitIDs(t, idx, Query{Text: "vocabulary", Sections: []string{"specs", "notes"}, Spaces: core.EverythingFilter()}), 2) } func TestSearchRejectsAnEmptySection(t *testing.T) { idx := indexCorpus(t, newCorpus(t, "~bigbes/specs", "rev1"). add("a.md", "---\ntitle: A\n---\n\nbody text here\n")) _, err := idx.Search(context.Background(), Query{Text: "body", Sections: []string{""}, Spaces: core.EverythingFilter()}) require.ErrorContains(t, err, "empty section") } func TestHitCarriesAPinnedAddress(t *testing.T) { idx := indexCorpus(t, newCorpus(t, "~bigbes/specs", "8f14e45fceea167a"). add("specs/storage.md", "---\nid: SPEC-0001\ntitle: Storage model\n---\n\nThe write path resolves a tree.\n")) res, err := idx.Search(context.Background(), Query{Text: "resolves", Spaces: core.EverythingFilter()}) require.NoError(t, err) require.Len(t, res.Hits, 1) h := res.Hits[0] require.Equal(t, space(t, "~bigbes/specs"), h.Space) require.Equal(t, "SPEC-0001", h.ID) require.Equal(t, "8f14e45fceea167a", h.Rev) require.Equal(t, "specs/storage.md", h.Path) require.Equal(t, "specs", h.Section) require.Equal(t, "Storage model", h.Title) require.Equal(t, LangEN, h.Lang) require.Greater(t, h.Score, 0.0) require.Contains(t, h.Snippet, "resolves") } // Snippets are rendered as HTML by the review UI, so the text around the marks // must be escaped. bleve's html formatter does it; this pins the behaviour. func TestSnippetIsHTMLEscaped(t *testing.T) { idx := indexCorpus(t, newCorpus(t, "~bigbes/specs", "rev1"). add("specs/x.md", "---\nid: SPEC-0001\ntitle: Escaping\n---\n\n"+ "A needle inside `` and more prose after it.\n")) res, err := idx.Search(context.Background(), Query{Text: "needle", Spaces: core.EverythingFilter()}) require.NoError(t, err) require.Len(t, res.Hits, 1) require.Contains(t, res.Hits[0].Snippet, "<script>") require.NotContains(t, res.Hits[0].Snippet, "