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