package mcpsrv
import (
"context"
"errors"
"html"
"strings"
"sourcecraft.dev/bigbes/sr-ht-spec/core"
"sourcecraft.dev/bigbes/sr-ht-spec/search"
)
type searchInput struct {
Query string `json:"query" jsonschema:"what to search for; matched over document titles and body text in both English and Russian"`
// Spaces is the project filter. The design's "a project is a saved filter
// over one global index, not a container" is this argument and nothing
// else, which is why it is a space list rather than a project name: a
// project's membership is resolved by whoever holds the project row, and
// what search takes is the set it resolves to.
Spaces []string `json:"spaces,omitempty" jsonschema:"restrict the search to these spaces, each written \"~owner/name\" as spec_list reports them. This is the project filter: a project on this service is a named set of spaces. Omit to search every space."`
Sections []string `json:"sections,omitempty" jsonschema:"restrict the search to these top-level sections (\"specs\", \"notes\", \"reports\"). Omit to search every section except the dated activity log; name \"log\" to search that."`
Limit int `json:"limit,omitempty" jsonschema:"maximum number of hits to return, clamped to 1..100"`
Offset int `json:"offset,omitempty" jsonschema:"how many of the top-ranked hits to skip, for paging through a large result set"`
}
// searchHit is one result as an agent sees it.
//
// It is search.Hit reshaped for a tool caller rather than the type itself:
// Snippet becomes plain text, and ID is split from Anchor so that every id
// reported here is one spec_read accepts.
type searchHit struct {
Space string `json:"space"`
// ID addresses the document in spec_read.
ID string `json:"id"`
// Path is the document's path in the space's git tree.
Path string `json:"path,omitempty"`
// Rev is the revision the document was indexed at — the approved head of
// its space at index time. Pass it to spec_read to pin the read to exactly
// what was searched.
Rev string `json:"rev,omitempty"`
// Anchor is the heading fragment a hit inside a dated activity log lands
// on. Empty for an ordinary document.
Anchor string `json:"anchor,omitempty"`
Title string `json:"title,omitempty"`
Section string `json:"section,omitempty"`
Score float64 `json:"score"`
// Snippet is the matching fragment as plain text.
Snippet string `json:"snippet,omitempty"`
}
type searchOutput struct {
Hits []searchHit `json:"hits"`
// Total is how many documents matched, not how many are in Hits.
Total uint64 `json:"total"`
}
func searchHandler(ctx context.Context, b Backend, in searchInput) (searchOutput, error) {
if err := requireRead(ctx); err != nil {
return searchOutput{}, err
}
text := strings.TrimSpace(in.Query)
if text == "" {
return searchOutput{}, errors.New("query must not be empty")
}
spaces, err := parseSpaceFilter(in.Spaces)
if err != nil {
return searchOutput{}, err
}
sections, err := trimAll("section", in.Sections)
if err != nil {
return searchOutput{}, err
}
res, err := b.Index.Search(ctx, search.Query{
Text: text,
Spaces: spaces,
Sections: sections,
Limit: clampLimit(in.Limit),
Offset: in.Offset,
})
if err != nil {
return searchOutput{}, err
}
out := searchOutput{Hits: make([]searchHit, 0, len(res.Hits)), Total: res.Total}
for _, h := range res.Hits {
out.Hits = append(out.Hits, searchHit{
Space: h.Space.String(),
ID: documentID(h.ID),
Path: h.Path,
Rev: h.Rev,
Anchor: h.Anchor,
Title: h.Title,
Section: h.Section,
Score: h.Score,
Snippet: plainSnippet(h.Snippet),
})
}
return out, nil
}
// parseSpaceFilter validates the project filter. An unparseable space is an
// error rather than a dropped filter term: dropping one would silently widen
// the search past the set the caller asked for, and a wider answer than
// requested is indistinguishable from a correct one.
//
// An omitted argument is every space — which the tool schema promises — and it
// is returned as the filter that says so. The distinction matters one layer
// down: "the agent named no spaces" is not the same as "the project the agent
// named holds no spaces", and only a filter can tell them apart.
func parseSpaceFilter(in []string) (core.SpaceFilter, error) {
if len(in) == 0 {
return core.EverythingFilter(), nil
}
refs := make([]core.SpaceRef, 0, len(in))
for _, s := range in {
ref, err := parseSpace(s)
if err != nil {
return core.SpaceFilter{}, err
}
refs = append(refs, ref)
}
return core.SpacesFilter(refs, nil), nil
}
// trimAll trims each element and refuses an empty one. search/ rejects an empty
// filter term outright; catching it here names the argument that carried it.
func trimAll(what string, in []string) ([]string, error) {
if len(in) == 0 {
return nil, nil
}
out := make([]string, 0, len(in))
for _, s := range in {
t := strings.TrimSpace(s)
if t == "" {
return nil, errors.New(what + " must not be empty")
}
out = append(out, t)
}
return out, nil
}
// documentID strips the entry suffix an activity-log hit carries.
//
// search/ indexes each dated entry of a log as its own document under
// "<page id>#<date>-<n>", so that a hit lands on the entry rather than on the
// whole log. That id is not a document id: spec_read would not resolve it. The
// entry's position is already reported separately as Anchor, so the split loses
// nothing and makes every id in a search result one an agent can hand straight
// to spec_read.
func documentID(id string) string {
if i := strings.IndexByte(id, '#'); i >= 0 {
return id[:i]
}
return id
}
// plainSnippet converts bleve's highlighted fragment to plain text.
//
// search.Hit.Snippet is HTML: the matched terms are wrapped in <mark> and
// everything around them is HTML-escaped, because the web UI renders it. A tool
// result is not rendered, so leaving it would show an agent literal "&" and
// "<mark>" and invite it to copy them into prose. Both are undone exactly
// rather than by a general tag stripper: the only markup bleve's formatter
// emits is that one tag pair, so removing it and unescaping restores the
// document's own text byte for byte.
//
// This is coupled to search/'s choice of highlighter. If that ever stops being
// bleve's default HTML formatter, this must change with it.
func plainSnippet(s string) string {
if s == "" {
return ""
}
s = strings.ReplaceAll(s, "<mark>", "")
s = strings.ReplaceAll(s, "</mark>", "")
return html.UnescapeString(s)
}