internal/control/cursor.go

dd06e80a071d451ebb7b083363e0580550481114
gitbay/internal/control/cursor.go history · blame · raw

159 lines · 4430 bytes

  1package control
  2
  3import (
  4	"encoding/base64"
  5	"errors"
  6	"fmt"
  7	"io"
  8	"reflect"
  9	"regexp"
 10	"strconv"
 11	"strings"
 12
 13	"gitbay.org/gitbay/internal/protocol"
 14)
 15
 16// bareWord matches arguments that need no quoting for the server-side
 17// POSIX tokenizer.
 18var bareWord = regexp.MustCompile(`^[A-Za-z0-9@%+=:,./_!-]+$`)
 19
 20// shellWord quotes one argument the way the gitbay CLI does, so a
 21// printed command can be pasted.
 22func shellWord(arg string) string {
 23	if arg != "" && bareWord.MatchString(arg) {
 24		return arg
 25	}
 26	return "'" + strings.ReplaceAll(arg, "'", `'\''`) + "'"
 27}
 28
 29// Cursor pagination. A cursor is opaque to clients: base64url of
 30// "<kind>:<key>", where key is the sort key of the last row of the
 31// previous page. The kind keeps a cursor minted by one command from
 32// being fed to another.
 33
 34const maxPageLimit = 200
 35
 36func encodeCursor(kind, key string) string {
 37	return base64.RawURLEncoding.EncodeToString([]byte(kind + ":" + key))
 38}
 39
 40func decodeCursor(kind, cursor string) (string, error) {
 41	raw, err := base64.RawURLEncoding.DecodeString(cursor)
 42	if err != nil {
 43		return "", errors.New("bad cursor")
 44	}
 45	k, key, ok := strings.Cut(string(raw), ":")
 46	if !ok || k != kind || key == "" {
 47		return "", errors.New("bad cursor")
 48	}
 49	return key, nil
 50}
 51
 52// page carries parsed --limit/--cursor flags. active marks that either
 53// flag was given: only then does the output switch to the paged shape.
 54type page struct {
 55	limit  int
 56	key    string // decoded cursor key, "" means from the start
 57	active bool
 58}
 59
 60// queryLimit is what the store is asked for: one row beyond the page, so
 61// the presence of a following page is known without a second query.
 62func (p page) queryLimit() int {
 63	if p.limit == 0 {
 64		return 0
 65	}
 66	return p.limit + 1
 67}
 68
 69// keyInt returns the cursor key as a number; parsePageFlags has already
 70// validated it for numeric kinds.
 71func (p page) keyInt() int64 {
 72	n, _ := strconv.ParseInt(p.key, 10, 64)
 73	return n
 74}
 75
 76// parsePageFlags strips --limit and --cursor from args. kind names the
 77// cursor namespace; numeric declares the sort key an integer.
 78func parsePageFlags(c *Ctx, args []string, kind string, numeric bool) (rest []string, p page, code int) {
 79	for i := 0; i < len(args); i++ {
 80		switch args[i] {
 81		case "--limit":
 82			if i+1 >= len(args) {
 83				return nil, p, c.fail(protocol.ExitUsage, "--limit requires a value")
 84			}
 85			n, err := strconv.Atoi(args[i+1])
 86			if err != nil || n < 1 || n > maxPageLimit {
 87				return nil, p, c.fail(protocol.ExitUsage, "--limit must be 1 to %d", maxPageLimit)
 88			}
 89			p.limit, p.active = n, true
 90			i++
 91		case "--cursor":
 92			if i+1 >= len(args) {
 93				return nil, p, c.fail(protocol.ExitUsage, "--cursor requires a value")
 94			}
 95			key, err := decodeCursor(kind, args[i+1])
 96			if err == nil && numeric {
 97				_, err = strconv.ParseInt(key, 10, 64)
 98			}
 99			if err != nil {
100				return nil, p, c.fail(protocol.ExitUsage, "bad cursor")
101			}
102			p.key, p.active = key, true
103			i++
104		default:
105			rest = append(rest, args[i])
106		}
107	}
108	return rest, p, -1
109}
110
111// trimPage cuts the probe row and derives the next cursor from the last
112// row kept.
113func trimPage[T any](p page, items []T, kind string, key func(T) string) ([]T, string) {
114	if p.limit == 0 || len(items) <= p.limit {
115		return items, ""
116	}
117	items = items[:p.limit]
118	return items, encodeCursor(kind, key(items[len(items)-1]))
119}
120
121// emitPage renders a list result. Without pagination flags the shape is
122// the bare array it has always been; with them the array moves under
123// "items" with the next cursor alongside.
124func (c *Ctx) emitPage(p page, items any, next string, plain func(w io.Writer)) int {
125	if !p.active {
126		return c.emit(items, plain)
127	}
128	if v := reflect.ValueOf(items); v.Kind() == reflect.Slice && v.IsNil() {
129		items = reflect.MakeSlice(v.Type(), 0, 0).Interface()
130	}
131	type out struct {
132		Items any    `json:"items"`
133		Next  string `json:"next,omitempty"`
134	}
135	return c.emit(out{items, next}, func(w io.Writer) {
136		plain(w)
137		if next == "" {
138			return
139		}
140		if c.Term.Cols == 0 {
141			fmt.Fprintf(w, "next\t%s\n", next)
142			return
143		}
144		var again []string
145		for i := 0; i < len(c.Argv); i++ {
146			if c.Argv[i] == "--cursor" {
147				i++
148				continue
149			}
150			again = append(again, c.Argv[i])
151		}
152		cmd := []string{"gitbay", joinPath(c.Cmd.Path)}
153		for _, a := range again {
154			cmd = append(cmd, shellWord(a))
155		}
156		cmd = append(cmd, "--cursor", next)
157		fmt.Fprintf(c.Stderr, "more: %s\n", strings.Join(cmd, " "))
158	})
159}