package mcpsrv
import (
"context"
"errors"
"fmt"
"strings"
"github.com/go-git/go-git/v5/plumbing"
"sourcecraft.dev/bigbes/sr-ht-spec/core"
"sourcecraft.dev/bigbes/sr-ht-spec/doc"
"sourcecraft.dev/bigbes/sr-ht-spec/gitx"
"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 four 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 fifth 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)
ResolveRev(ctx context.Context, sp *service.Space, rev string) (string, error)
ListDocuments(ctx context.Context, sp *service.Space, rev string) ([]service.Document, 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 builds the addressable document
// set, returning it together with the bodies keyed by path and the resolved
// commit.
//
// The revision is resolved first and everything below is read 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) {
resolved, err := b.Docs.ResolveRev(ctx, sp, rev)
if err != nil {
return nil, nil, "", err
}
docs, err := b.Docs.ListDocuments(ctx, sp, resolved)
if err != nil {
return nil, nil, "", err
}
converted, err := toGitDocuments(docs)
if err != nil {
return nil, nil, "", err
}
arc := doc.FromDocuments(sp.Ref, resolved, converted)
bodies := make(map[string][]byte, len(docs))
for _, d := range docs {
bodies[d.Path] = d.Data
}
return arc, bodies, resolved, nil
}
// toGitDocuments converts service/'s hex-object-name documents back into the
// shape doc.FromDocuments takes.
//
// This conversion should not exist, and it is the one place this package
// reaches below service/. service.Document carries Blob and Rev as hex strings
// precisely so that api/, mcpsrv/, graph/ and web/ need not import gitx — but
// doc.FromDocuments, which owns the addressing rule those surfaces have to
// apply, takes []gitx.Document. Until service/ exposes the archive itself,
// something above it has to bridge the two, and doing it here in four lines is
// better than restating the addressing rule in Go. A malformed sha is an
// error, not a zero hash: plumbing.NewHash silently yields the zero value for
// anything it cannot parse, and a document whose render-cache key is zero is a
// cache collision waiting to happen.
func toGitDocuments(docs []service.Document) ([]gitx.Document, error) {
out := make([]gitx.Document, 0, len(docs))
for _, d := range docs {
if !plumbing.IsHash(d.Blob) {
return nil, fmt.Errorf("document %q carries a malformed blob id %q", d.Path, d.Blob)
}
out = append(out, gitx.Document{Path: d.Path, Blob: plumbing.NewHash(d.Blob), Data: d.Data})
}
return out, 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
}