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, "