package mcpsrv
import (
"context"
"errors"
"fmt"
"strings"
"sourcecraft.dev/bigbes/sr-ht-spec/core"
"sourcecraft.dev/bigbes/sr-ht-spec/doc"
"sourcecraft.dev/bigbes/sr-ht-spec/search"
"sourcecraft.dev/bigbes/sr-ht-spec/service"
)
// Reader is the part of the orchestration layer the read tools call.
// *service.Service satisfies it.
//
// It is deliberately three methods. Every one of them is the same function the
// REST handlers, the GraphQL resolvers and the web UI call, which is what the
// design means by "the MCP tools call the same resolver layer, not a parallel
// implementation" — a fourth method here that service/ does not have would be
// the beginning of a second implementation.
//
// ReadDocument is absent on purpose. Addressing a document by id needs the
// space's whole document set anyway (see resolvePage), and reading the body
// from that set rather than issuing a second, path-only read keeps one code
// path instead of two that must agree about what an id names.
type Reader interface {
ListSpaces(ctx context.Context) ([]*service.Space, error)
OpenSpace(ctx context.Context, ref core.SpaceRef) (*service.Space, error)
// Archive resolves rev and returns the space's addressable document set at
// it, with every document's bytes, from one tree walk. It replaced a
// ResolveRev/ListDocuments pair here, and the conversion back into git
// documents this package used to perform to reach doc.FromDocuments.
Archive(ctx context.Context, sp *service.Space, rev string) (*doc.Archive, map[string][]byte, error)
}
// Searcher is the query side of the one global index. *search.Index satisfies
// it.
type Searcher interface {
Search(ctx context.Context, q search.Query) (search.Results, error)
}
// Compile-time assertions that the production types satisfy the interfaces
// this package is written against. They are here rather than in a test so that
// a signature change in service/ or search/ breaks the build of the package
// that depends on them, not of a test somebody may not run.
var (
_ Reader = (*service.Service)(nil)
_ Searcher = (*search.Index)(nil)
)
const (
// minRevLen and maxRevLen bound a git object name. They are the design's
// X-Agent-Base grammar — 7-64 lowercase hex — reused here so that "a
// revision an agent may name" has one spelling across every surface.
minRevLen = 7
maxRevLen = 64
)
// parseSpace resolves the tool's space argument, which is written the way the
// design writes a space everywhere else: "~owner/name".
func parseSpace(s string) (core.SpaceRef, error) {
trimmed := strings.TrimSpace(s)
if trimmed == "" {
return core.SpaceRef{}, errors.New("space must not be empty; call spec_list with no arguments to list spaces")
}
ref, err := core.ParseSpaceRef(trimmed)
if err != nil {
return core.SpaceRef{}, fmt.Errorf("space %q: %w", trimmed, err)
}
return ref, nil
}
// parseRev turns the tool's optional rev argument into a revision string for
// service/. An empty argument becomes service.ApprovedRev, which is the read
// contract's default: the approved head.
//
// Anything else must be a git object name. Ref names are refused even though
// service/ would happily resolve them, and that refusal is the whole point:
// "proposals/42" is a legal revision one layer down, so accepting it here
// would make unreviewed proposal content reachable by a plausible-looking
// argument. Reading it requires naming a commit sha, which no read tool ever
// hands back, so it cannot be reached by accident.
func parseRev(s string) (string, error) {
rev := strings.TrimSpace(s)
if rev == "" {
return service.ApprovedRev, nil
}
if len(rev) < minRevLen || len(rev) > maxRevLen {
return "", fmt.Errorf("rev %q must be a git object name of %d-%d hex characters; "+
"omit rev to read the approved head", rev, minRevLen, maxRevLen)
}
for i := 0; i < len(rev); i++ {
c := rev[i]
if (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') {
continue
}
return "", fmt.Errorf("rev %q must be a git object name (lowercase hex), not a branch or ref name; "+
"omit rev to read the approved head", rev)
}
return rev, nil
}
// archiveAt reads a space at a revision and returns its addressable document
// set, the bodies keyed by path, and the resolved commit.
//
// service/ does the work: it resolves the revision first and reads at the
// resolved sha, never at the caller's string. That is what makes an unpinned
// read pinnable — the rev reported back names the exact bytes returned, so a
// merge landing between the resolve and the read cannot make one answer
// describe two revisions.
//
// The whole space is read for one document. At the confirmed volume — tens of
// documents a day — that is cheap, and the alternative is worse: doc.Archive is
// where the design's addressing rule (id when valid and unique, else path)
// actually lives, and it is built from a revision's whole document set. A
// path-only fast path would be a second addressing implementation.
func archiveAt(ctx context.Context, b Backend, sp *service.Space, rev string) (*doc.Archive, map[string][]byte, string, error) {
arc, bodies, err := b.Docs.Archive(ctx, sp, rev)
if err != nil {
return nil, nil, "", err
}
return arc, bodies, arc.Rev, nil
}
// resolvePage applies the design's addressing rule to one caller-supplied
// token, using the archive's own lookups rather than reimplementing them:
//
// - a document with a well-formed id that no other document in its space
// claims is addressed by that id;
// - a document whose id is absent, malformed or duplicated is addressed by
// its path — with or without the ".md" extension, since the read plane's
// URL grammar carries no extension and an agent copying a path out of a
// search hit has one.
//
// A token naming an id that two documents claim resolves to neither, and says
// so. Silently picking one would point an agent at a document its author did
// not mean, and the ambiguity would be invisible.
func resolvePage(arc *doc.Archive, token string) (*doc.Page, error) {
name := strings.TrimSpace(token)
if name == "" {
return nil, errors.New("document must not be empty")
}
if p, ok := arc.Page(name); ok {
return p, nil
}
if p, ok := arc.ByPath(name); ok {
return p, nil
}
if p, ok := arc.ByPath(name + ".md"); ok {
return p, nil
}
if dup := duplicateIDPaths(arc, name); len(dup) > 1 {
return nil, fmt.Errorf("document id %q is claimed by %d documents (%s) and resolves to none of them; "+
"address one of them by path instead", name, len(dup), strings.Join(dup, ", "))
}
return nil, fmt.Errorf("no document %q in %s at %s", name, arc.Space, arc.Rev)
}
// duplicateIDPaths lists the paths of every document claiming id. doc/ excludes
// a duplicated id from the archive's lookup, which is the correct resolution
// but leaves nothing to report; this walk exists only to turn "not found" into
// a message that names the collision.
func duplicateIDPaths(arc *doc.Archive, id string) []string {
var paths []string
for _, p := range arc.All() {
if p.DocID == id {
paths = append(paths, p.Path)
}
}
return paths
}