// Package api owns RegisterAll — the one explicit wiring point where every
// op joins the registry (0003 §2: no init() magic; the wiring stays
// greppable) — plus the HTTP router that serves the registered ops. This
// is the sanctioned place Run closures over the data planes are built;
// the arch gate keeps every other consumer on the op side of the seam.
package api
import (
"bytes"
"context"
"errors"
"fmt"
"html"
"net/url"
"sort"
"unicode/utf8"
"craftdesign.group/quarry/internal/highlight"
"craftdesign.group/quarry/internal/index"
"craftdesign.group/quarry/internal/markup"
"craftdesign.group/quarry/internal/ops"
"craftdesign.group/quarry/internal/zitcli"
)
// Repo is one registered store's data-plane pair.
type Repo struct {
Index *index.Index
Zit *zitcli.Client
}
// Deps carries what op implementations close over. Repos may be empty
// (`quarry spec` runs storeless); ops answer unknown repos with
// not-found, and the spec stays complete either way.
type Deps struct {
Version string
Repos map[string]*Repo
}
// repo resolves one registered store; unknown names are not-found.
func (d Deps) repo(name string) (*Repo, error) {
if r, ok := d.Repos[name]; ok {
return r, nil
}
return nil, fmt.Errorf("no repository %q: %w", name, ops.ErrNotFound)
}
type versionIn struct{}
type versionOut struct {
Version string `json:"version"`
}
type repoListIn struct{}
type repoInfo struct {
Name string `json:"name"`
Checkins int `json:"checkins"`
TipDate string `json:"tipDate"`
TipComment string `json:"tipComment"`
}
type docsIn struct{}
type docsOut struct {
Markdown string `json:"markdown"`
HTML string `json:"html"`
}
type repoListOut struct {
Repos []repoInfo `json:"repos"`
Count int `json:"count"`
}
type timelineIn struct {
Repo string `json:"repo" validate:"required,max=64"`
Limit int `json:"limit" validate:"min=0,max=200,default=50"`
Before string `json:"before" validate:"max=96"`
Branch string `json:"branch" validate:"max=64"`
Y string `json:"y" validate:"oneof=all|ci|w|t"`
}
type timelineOut struct {
Entries []index.TimelineEntry `json:"entries"`
Count int `json:"count"`
// Next is the `before` cursor for the following (older) page;
// empty when this page is the last.
Next string `json:"next"`
}
type wikiListIn struct {
Repo string `json:"repo" validate:"required,max=64"`
}
type wikiListOut struct {
Pages []index.WikiTitle `json:"pages"`
Count int `json:"count"`
}
type wikiGetIn struct {
Repo string `json:"repo" validate:"required,max=64"`
Name string `json:"name" validate:"required,max=100"`
Rev string `json:"rev" validate:"max=64"` // artifact hash prefix; empty = latest
}
type wikiGetOut struct {
Hash string `json:"hash"`
Title string `json:"title"`
Date string `json:"date"`
User string `json:"user"`
Mimetype string `json:"mimetype"`
Source string `json:"source"`
HTML string `json:"html"`
}
type wikiHistoryIn struct {
Repo string `json:"repo" validate:"required,max=64"`
Name string `json:"name" validate:"required,max=100"`
}
type wikiHistoryOut struct {
Title string `json:"title"`
Edits []index.WikiRev `json:"edits"`
Count int `json:"count"`
}
type fileGetIn struct {
Repo string `json:"repo" validate:"required,max=64"`
Hash string `json:"hash" validate:"required,min=4,max=64"`
Path string `json:"path" validate:"required,max=512"`
}
type fileGetOut struct {
Hash string `json:"hash"`
Path string `json:"path"`
Size int `json:"size"`
Binary bool `json:"binary"`
Content string `json:"content"`
HTML string `json:"html"` // highlighted, sanitizer-gated; empty when no lexer matched
}
type searchIn struct {
Repo string `json:"repo" validate:"required,max=64"`
Q string `json:"q" validate:"required,max=200"`
Limit int `json:"limit" validate:"min=0,max=200,default=50"`
}
type searchOut struct {
Results []index.SearchResult `json:"results"`
Count int `json:"count"`
}
type diffGetIn struct {
Repo string `json:"repo" validate:"required,max=64"`
Hash string `json:"hash" validate:"required,min=4,max=64"`
}
type diffGetOut struct {
From string `json:"from"`
To string `json:"to"`
Files []zitcli.DiffEntry `json:"files"`
Unified string `json:"unified"`
Note string `json:"note"` // set when the diff could not be fully computed
}
type checkinGetIn struct {
Repo string `json:"repo" validate:"required,max=64"`
Hash string `json:"hash" validate:"required,min=4,max=64"`
}
type checkinGetOut struct {
Hash string `json:"hash"`
Branch string `json:"branch"`
Date string `json:"date"`
User string `json:"user"`
Comment string `json:"comment"`
Parents []string `json:"parents"`
Files []zitcli.LsEntry `json:"files"`
}
type ticketListIn struct {
Repo string `json:"repo" validate:"required,max=64"`
Limit int `json:"limit" validate:"min=0,max=200,default=50"`
Before string `json:"before" validate:"max=96"`
}
// ticketSummary is the list row: id plus the two fields every tracker
// listing needs, pulled out of the folded state.
type ticketSummary struct {
ID string `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
Updated string `json:"updated"`
}
type ticketListOut struct {
Tickets []ticketSummary `json:"tickets"`
Count int `json:"count"`
// Next is the `before` cursor for the following (older-updated)
// page; empty when this page is the last.
Next string `json:"next"`
}
type ticketGetIn struct {
Repo string `json:"repo" validate:"required,max=64"`
ID string `json:"id" validate:"required,min=4,max=40"`
}
type ticketGetOut struct {
Ticket index.TicketState `json:"ticket"`
}
func fieldValue(fields []index.TicketFieldSet, name string) string {
for _, f := range fields {
if f.Name == name {
return f.Value
}
}
return ""
}
// resolver implements markup.Resolver over one repo's index. Wiki names
// resolve to existing pages; hash prefixes resolve to uniquely matching
// check-ins or tickets. An unresolvable or ambiguous reference renders
// as plain text, never as a dead link.
type resolver struct {
ix *index.Index
repo string
}
func (r resolver) ResolveHash(prefix string) string {
if r.ix == nil {
return ""
}
if e, _, ok, err := r.ix.CheckinByPrefix(prefix); err == nil && ok {
return "/r/" + r.repo + "/checkin/" + e.Hash
}
if t, ok, err := r.ix.TicketByPrefix(prefix); err == nil && ok {
return "/r/" + r.repo + "/ticket/" + t.ID
}
return ""
}
func (r resolver) ResolveWiki(name string) string {
if r.ix == nil {
return ""
}
if _, ok, err := r.ix.WikiPage(name); err == nil && ok {
return "/r/" + r.repo + "/wiki/" + url.PathEscape(name)
}
return ""
}
// RegisterAll wires every op.
func RegisterAll(r *ops.Registry, d Deps) error {
if err := ops.Register(r, ops.Op[versionIn, versionOut]{
Name: "meta.version",
Doc: "Report the quarry build version.",
Kind: ops.Query,
Auth: ops.Anonymous,
Route: "GET /api/v1/version",
Run: func(_ context.Context, _ versionIn) (versionOut, error) {
return versionOut{Version: d.Version}, nil
},
}); err != nil {
return err
}
if err := ops.Register(r, ops.Op[docsIn, docsOut]{
Name: "meta.docs",
Doc: "The API reference, generated from this registry and rendered.",
Kind: ops.Query,
Auth: ops.Anonymous,
Route: "GET /api/v1/docs",
Run: func(_ context.Context, _ docsIn) (docsOut, error) {
// r is complete by the time any op runs; the reference
// documents the same registry that serves it.
md := ops.Docs(r, "quarry")
rendered, err := markup.Render(md, resolver{})
if err != nil {
return docsOut{}, err
}
return docsOut{Markdown: string(md), HTML: string(rendered)}, nil
},
}); err != nil {
return err
}
if err := ops.Register(r, ops.Op[repoListIn, repoListOut]{
Name: "repo.list",
Doc: "List the served repositories with their tip check-in.",
Kind: ops.Query,
Auth: ops.Anonymous,
Route: "GET /api/v1/repos",
Run: func(_ context.Context, _ repoListIn) (repoListOut, error) {
names := make([]string, 0, len(d.Repos))
for name := range d.Repos {
names = append(names, name)
}
sort.Strings(names)
out := repoListOut{Count: len(names)}
for _, name := range names {
info := repoInfo{Name: name}
if ix := d.Repos[name].Index; ix != nil {
info.Checkins, _ = ix.CheckinCount()
if tip, _, err := ix.Timeline(1, "", "", "ci"); err == nil && len(tip) == 1 {
info.TipDate, info.TipComment = tip[0].Date, tip[0].Comment
}
}
out.Repos = append(out.Repos, info)
}
return out, nil
},
}); err != nil {
return err
}
if err := ops.Register(r, ops.Op[timelineIn, timelineOut]{
Name: "timeline.list",
Doc: "List a repository's events — check-ins, wiki edits, ticket changes — newest first, paged by `before`.",
Kind: ops.Query,
Auth: ops.Anonymous,
Route: "GET /api/v1/r/{repo}/timeline",
Run: func(_ context.Context, in timelineIn) (timelineOut, error) {
repo, err := d.repo(in.Repo)
if err != nil {
return timelineOut{}, err
}
entries, more, err := repo.Index.Timeline(in.Limit, in.Before, in.Branch, in.Y)
if err != nil {
return timelineOut{}, err
}
out := timelineOut{Entries: entries, Count: len(entries)}
if more && len(entries) > 0 {
last := entries[len(entries)-1]
out.Next = last.Date + "~" + last.Hash
}
return out, nil
},
}); err != nil {
return err
}
if err := ops.Register(r, ops.Op[wikiListIn, wikiListOut]{
Name: "wiki.list",
Doc: "List a repository's wiki pages with their latest edit.",
Kind: ops.Query,
Auth: ops.Anonymous,
Route: "GET /api/v1/r/{repo}/wiki",
Run: func(_ context.Context, in wikiListIn) (wikiListOut, error) {
repo, err := d.repo(in.Repo)
if err != nil {
return wikiListOut{}, err
}
pages, err := repo.Index.WikiTitles()
if err != nil {
return wikiListOut{}, err
}
return wikiListOut{Pages: pages, Count: len(pages)}, nil
},
}); err != nil {
return err
}
if err := ops.Register(r, ops.Op[ticketListIn, ticketListOut]{
Name: "ticket.list",
Doc: "List a repository's tickets with their folded current state.",
Kind: ops.Query,
Auth: ops.Anonymous,
Route: "GET /api/v1/r/{repo}/ticket",
Run: func(_ context.Context, in ticketListIn) (ticketListOut, error) {
repo, err := d.repo(in.Repo)
if err != nil {
return ticketListOut{}, err
}
all, more, err := repo.Index.TicketSummaries(in.Limit, in.Before)
if err != nil {
return ticketListOut{}, err
}
out := ticketListOut{Count: len(all)}
for _, t := range all {
out.Tickets = append(out.Tickets, ticketSummary{
ID: t.ID, Title: t.Title, Status: t.Status, Updated: t.Updated,
})
}
if more && len(all) > 0 {
last := all[len(all)-1]
out.Next = last.Updated + "~" + last.ID
}
return out, nil
},
}); err != nil {
return err
}
if err := ops.Register(r, ops.Op[ticketGetIn, ticketGetOut]{
Name: "ticket.get",
Doc: "Fetch one ticket: folded state plus change history.",
Kind: ops.Query,
Auth: ops.Anonymous,
Route: "GET /api/v1/r/{repo}/ticket/{id}",
Run: func(_ context.Context, in ticketGetIn) (ticketGetOut, error) {
repo, err := d.repo(in.Repo)
if err != nil {
return ticketGetOut{}, err
}
t, ok, err := repo.Index.TicketByPrefix(in.ID)
if err != nil {
return ticketGetOut{}, err
}
if !ok {
return ticketGetOut{}, fmt.Errorf("no unique ticket for %q: %w", in.ID, ops.ErrNotFound)
}
return ticketGetOut{Ticket: t}, nil
},
}); err != nil {
return err
}
if err := ops.Register(r, ops.Op[searchIn, searchOut]{
Name: "search.query",
Doc: "Full-text search over a repository's check-ins, wiki, and tickets.",
Kind: ops.Query,
Auth: ops.Anonymous,
Route: "GET /api/v1/r/{repo}/search",
Run: func(_ context.Context, in searchIn) (searchOut, error) {
repo, err := d.repo(in.Repo)
if err != nil {
return searchOut{}, err
}
results, err := repo.Index.Search(in.Q, in.Limit)
if err != nil {
return searchOut{}, err
}
return searchOut{Results: results, Count: len(results)}, nil
},
}); err != nil {
return err
}
if err := ops.Register(r, ops.Op[diffGetIn, diffGetOut]{
Name: "checkin.diff",
Doc: "Diff one check-in against its primary parent.",
Kind: ops.Query,
Auth: ops.Anonymous,
Route: "GET /api/v1/r/{repo}/checkin/{hash}/diff",
Run: func(_ context.Context, in diffGetIn) (diffGetOut, error) {
repo, err := d.repo(in.Repo)
if err != nil {
return diffGetOut{}, err
}
e, parents, ok, err := repo.Index.CheckinByPrefix(in.Hash)
if err != nil {
return diffGetOut{}, err
}
if !ok {
return diffGetOut{}, fmt.Errorf("no unique check-in for %q: %w", in.Hash, ops.ErrNotFound)
}
out := diffGetOut{To: e.Hash}
if len(parents) == 0 {
// A root check-in has nothing to diff against; its
// whole file set is the change.
files, err := repo.Zit.Ls(e.Hash)
if err != nil {
return diffGetOut{}, err
}
for _, f := range files {
out.Files = append(out.Files, zitcli.DiffEntry{Status: "A", Name: f.Name})
}
return out, nil
}
out.From = parents[0]
unified, brief, err := repo.Zit.Diff(out.From, e.Hash)
if err != nil {
return diffGetOut{}, err
}
out.Unified, out.Files = unified, brief
// zit exits 0 with empty output when changed content is
// unavailable (shunned) — flag it rather than serving what
// looks like a legitimate no-op diff.
if out.Unified == "" && len(out.Files) > 0 {
out.Note = "diff content unavailable — the changed file content may have been shunned from the store"
}
return out, nil
},
}); err != nil {
return err
}
if err := ops.Register(r, ops.Op[fileGetIn, fileGetOut]{
Name: "file.get",
Doc: "Fetch one file's content at a check-in.",
Kind: ops.Query,
Auth: ops.Anonymous,
Route: "GET /api/v1/r/{repo}/file/{hash}/{path...}",
Run: func(_ context.Context, in fileGetIn) (fileGetOut, error) {
repo, err := d.repo(in.Repo)
if err != nil {
return fileGetOut{}, err
}
e, _, ok, err := repo.Index.CheckinByPrefix(in.Hash)
if err != nil {
return fileGetOut{}, err
}
if !ok {
return fileGetOut{}, fmt.Errorf("no unique check-in for %q: %w", in.Hash, ops.ErrNotFound)
}
raw, err := repo.Zit.Cat(in.Path, e.Hash)
if errors.Is(err, zitcli.ErrNoEntry) {
// Clean public message; the zit detail stays behind.
return fileGetOut{}, fmt.Errorf("no file %q at this check-in: %w", in.Path, ops.ErrNotFound)
}
if err != nil {
return fileGetOut{}, err
}
out := fileGetOut{Hash: e.Hash, Path: in.Path, Size: len(raw)}
// Binary content stays out of the JSON surface; the page
// says "binary" and the artifact read verb serves bytes.
if utf8.Valid(raw) && !bytes.ContainsRune(raw, 0) {
out.Content = string(raw)
// Best-effort highlighting; sanitized like all rendered
// HTML even though quarry generated it.
if hl, ok := highlight.Code(raw, "", in.Path); ok {
out.HTML = markup.Sanitize(hl)
}
} else {
out.Binary = true
}
return out, nil
},
}); err != nil {
return err
}
if err := ops.Register(r, ops.Op[checkinGetIn, checkinGetOut]{
Name: "checkin.get",
Doc: "Describe one check-in: metadata, parents, and its file set.",
Kind: ops.Query,
Auth: ops.Anonymous,
Route: "GET /api/v1/r/{repo}/checkin/{hash}",
Run: func(_ context.Context, in checkinGetIn) (checkinGetOut, error) {
repo, err := d.repo(in.Repo)
if err != nil {
return checkinGetOut{}, err
}
e, parents, ok, err := repo.Index.CheckinByPrefix(in.Hash)
if err != nil {
return checkinGetOut{}, err
}
if !ok {
return checkinGetOut{}, fmt.Errorf("no unique check-in for %q: %w", in.Hash, ops.ErrNotFound)
}
out := checkinGetOut{
Hash: e.Hash, Branch: e.Branch, Date: e.Date, User: e.User,
Comment: e.Comment, Parents: parents,
}
// File set is an on-demand zit read (0001 §2), not index
// state — the index answers "which check-in", the store
// answers "what does it hold".
if repo.Zit != nil {
files, err := repo.Zit.Ls(e.Hash)
if err != nil {
return checkinGetOut{}, err
}
out.Files = files
}
return out, nil
},
}); err != nil {
return err
}
if err := ops.Register(r, ops.Op[wikiHistoryIn, wikiHistoryOut]{
Name: "wiki.history",
Doc: "List every recorded edit of one wiki page, newest first.",
Kind: ops.Query,
Auth: ops.Anonymous,
Route: "GET /api/v1/r/{repo}/wiki/{name}/history",
Run: func(_ context.Context, in wikiHistoryIn) (wikiHistoryOut, error) {
repo, err := d.repo(in.Repo)
if err != nil {
return wikiHistoryOut{}, err
}
edits, err := repo.Index.WikiHistory(in.Name)
if err != nil {
return wikiHistoryOut{}, err
}
if len(edits) == 0 {
return wikiHistoryOut{}, fmt.Errorf("no wiki page %q: %w", in.Name, ops.ErrNotFound)
}
return wikiHistoryOut{Title: in.Name, Edits: edits, Count: len(edits)}, nil
},
}); err != nil {
return err
}
return ops.Register(r, ops.Op[wikiGetIn, wikiGetOut]{
Name: "wiki.get",
Doc: "Fetch one wiki page, rendered — latest, or the revision named by rev.",
Kind: ops.Query,
Auth: ops.Anonymous,
Route: "GET /api/v1/r/{repo}/wiki/{name}",
Run: func(_ context.Context, in wikiGetIn) (wikiGetOut, error) {
repo, err := d.repo(in.Repo)
if err != nil {
return wikiGetOut{}, err
}
page, ok, err := repo.Index.WikiRevision(in.Name, in.Rev)
if err != nil {
return wikiGetOut{}, err
}
if !ok {
return wikiGetOut{}, fmt.Errorf("no wiki page %q rev %q: %w", in.Name, in.Rev, ops.ErrNotFound)
}
var rendered []byte
switch page.Mimetype {
case "", "text/x-markdown", "text/markdown":
rendered, err = markup.Render(page.Body, resolver{ix: repo.Index, repo: in.Repo})
if err != nil {
return wikiGetOut{}, err
}
default:
// Non-markdown wiki content renders as escaped text.
rendered = []byte("<pre>" + html.EscapeString(string(page.Body)) + "</pre>")
}
return wikiGetOut{
Hash: page.Hash, Title: page.Title, Date: page.Date, User: page.User,
Mimetype: page.Mimetype, Source: string(page.Body),
HTML: string(rendered),
}, nil
},
})
}