package api
import (
"context"
"fmt"
"slices"
"strings"
"craftdesign.group/quarry/internal/ops"
)
type treeFindIn struct {
Repo string `json:"repo" validate:"required,max=64"`
// Version is the tree being read, as tree.list takes it: any name
// zit resolves, empty for the default branch's tip.
Version string `json:"version" validate:"max=100"`
Q string `json:"q" validate:"required,max=200"`
Limit int `json:"limit" validate:"min=0,max=50,default=8"`
}
type treeFindOut struct {
// Version is the check-in the paths were found in, so a link names
// the tree that was searched rather than a name that may move.
Version string `json:"version"`
Paths []string `json:"paths"`
// Count is every path that matched, not just the ones returned.
Count int `json:"count"`
}
// registerTreeFind is the Files page's finder: paths at one version
// that contain the words typed, best first.
//
// It reads the check-in's name listing, which zitcli keeps per check-in
// — a hash's listing cannot change — so typing matches in memory after
// the first keystroke. On pkgsrc, the largest tree here, the listing is
// 68,588 paths and 0.5s to read once.
func registerTreeFind(r *ops.Registry, d Deps) error {
return ops.Register(r, ops.Op[treeFindIn, treeFindOut]{
Name: "tree.find",
Caps: "o",
Doc: "Find files in a check-in by path, best match first.",
Kind: ops.Query,
Auth: ops.Anonymous,
Route: "GET /api/v1/r/{repo}/find",
Run: func(ctx context.Context, in treeFindIn) (treeFindOut, error) {
repo, err := d.repoFor(ctx, in.Repo)
if err != nil {
return treeFindOut{}, err
}
_, version := d.treeVersion(repo, in.Version)
files, err := repo.Zit.LsNames(ctx, version)
if err != nil {
return treeFindOut{}, fmt.Errorf("%w: %v", ops.ErrNotFound, err)
}
names := make([]string, len(files))
for i, f := range files {
names[i] = f.Name
}
paths, n := findPaths(names, in.Q, in.Limit)
return treeFindOut{Version: version, Paths: paths, Count: n}, nil
},
})
}
// findPaths returns the paths holding every word of q, case aside, and
// how many there were. Best first: a file whose name starts with the
// words, then one whose name holds them, then one whose folders do;
// within each, the shorter path, since it is the less specific and more
// often the one meant.
func findPaths(names []string, q string, limit int) ([]string, int) {
words := strings.Fields(strings.ToLower(q))
if len(words) == 0 {
return []string{}, 0
}
type hit struct {
path string
rank int
}
var hits []hit
for _, p := range names {
lp := strings.ToLower(p)
base := lp[strings.LastIndexByte(lp, '/')+1:]
rank, ok := 0, true
for i, w := range words {
if !strings.Contains(lp, w) {
ok = false
break
}
// The first word decides the rank: it is what the reader
// typed first and is most likely the file's own name.
if i == 0 {
switch {
case strings.HasPrefix(base, w):
case strings.Contains(base, w):
rank = 1
default:
rank = 2
}
}
}
if ok {
hits = append(hits, hit{p, rank})
}
}
slices.SortFunc(hits, func(a, b hit) int {
if a.rank != b.rank {
return a.rank - b.rank
}
if len(a.path) != len(b.path) {
return len(a.path) - len(b.path)
}
return strings.Compare(a.path, b.path)
})
out := make([]string, 0, min(limit, len(hits)))
for _, h := range hits[:min(limit, len(hits))] {
out = append(out, h.path)
}
return out, len(hits)
}