internal/control/cursor.go
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}