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}) 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"})) } // 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"}) 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.SpaceRef{rfcs.Space, ops.Space}}) 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.SpaceRef{other.Space}})) } // 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.SpaceRef{home.Space}})) } 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.SpaceRef{{}}}) 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"})) // 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}})) require.Equal(t, []string{"log"}, hitIDs(t, idx, Query{Text: "Log", Sections: []string{doc.LogSection}}), "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"}})) require.Equal(t, []string{"NOTE-0001"}, hitIDs(t, idx, Query{Text: "vocabulary", Sections: []string{"notes"}})) require.Len(t, hitIDs(t, idx, Query{Text: "vocabulary", Sections: []string{"specs", "notes"}}), 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{""}}) 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"}) 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"}) 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, "