Skip to content
quarry Artifact [a9fc7b07e5]
Login

Artifact [a9fc7b07e5]

Artifact a9fc7b07e57280dd68ea5018a265590bebc9ef4a4bb619fc10f122be52287834:


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, err := d.treeVersion(ctx, in.Repo, repo, in.Version)
			if err != nil {
				return treeFindOut{}, err
			}
			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)
}