internal/control/help.go

228 lines · 7231 bytes

  1package control
  2
  3import (
  4	"fmt"
  5	"io"
  6	"slices"
  7	"strings"
  8
  9	"gitbay.org/gitbay/internal/protocol"
 10	"gitbay.org/gitbay/internal/termtext"
 11)
 12
 13func init() {
 14	register(Command{
 15		Path:     []string{"help"},
 16		Summary:  "list available commands",
 17		Usage:    "help [<prefix>...]",
 18		Examples: []string{"help", "help repo"},
 19		ReadOnly: true,
 20		Run:      runHelp,
 21	})
 22}
 23
 24// nounSummaries is one line per distinct first path element in the
 25// registry, reusing the CLI's group short texts (cmd/gitbay/main.go)
 26// where the noun matches, so the two agree.
 27var nounSummaries = map[string]string{
 28	"account":       "export or import your account, for instance migration",
 29	"admin":         "instance administration (admins)",
 30	"audit":         "instance audit log (admins)",
 31	"build":         "CI builds",
 32	"dashboard":     "pinned repos, open MRs, assigned issues, recent builds",
 33	"email":         "manage email addresses",
 34	"explore":       "public repositories on this instance",
 35	"feed":          "activity on repositories you can reach",
 36	"help":          "list available commands",
 37	"issue":         "issues",
 38	"keys":          "manage SSH keys",
 39	"label":         "issue labels",
 40	"milestone":     "group issues and MRs toward a release",
 41	"mr":            "merge requests",
 42	"notifications": "your notification inbox",
 43	"org":           "organizations",
 44	"pgp":           "manage OpenPGP keys",
 45	"profile":       "user and org profiles",
 46	"register":      "create an account on this instance",
 47	"release":       "tag-anchored releases with notes and assets",
 48	"repo":          "create and manage repositories",
 49	"runner":        "the claim/report loop CI runners use",
 50	"search":        "find repositories, issues and merge requests",
 51	"snippet":       "shared text files, outside any repository",
 52	"status":        "commit statuses (CI)",
 53	"token":         "API tokens (minted over SSH, used with the JSON API)",
 54	"web":           "browser session",
 55	"webhook":       "outbound event delivery",
 56	"whoami":        "show the authenticated account",
 57	"wiki":          "a repository's wiki pages",
 58}
 59
 60// NounSummaries returns nounSummaries, for the CLI group-text agreement
 61// test (task 4.5).
 62func NounSummaries() map[string]string { return nounSummaries }
 63
 64// helpEntry is one row of the registry as help reports it.
 65type helpEntry struct {
 66	Path     string   `json:"path"`
 67	Summary  string   `json:"summary"`
 68	Usage    string   `json:"usage"`
 69	Flags    []Flag   `json:"flags,omitempty"`
 70	Examples []string `json:"examples,omitempty"`
 71}
 72
 73// runHelp lists the registry, sorted, so a noun's commands sit together.
 74// Bare `help` stays one line per command. A prefix that names one command
 75// exactly renders its full USAGE/FLAGS/EXAMPLES; a prefix that names a
 76// noun with several commands under it renders a READ/WRITE summary.
 77func runHelp(c *Ctx, args []string) int {
 78	prefix := joinPath(args)
 79	var matched []Command
 80	for _, cmd := range registry {
 81		p := joinPath(cmd.Path)
 82		if prefix == "" || p == prefix || strings.HasPrefix(p, prefix+" ") {
 83			matched = append(matched, cmd)
 84		}
 85	}
 86	if len(matched) == 0 {
 87		return c.fail(protocol.ExitNotFound, "no command matches %q; try: help", prefix)
 88	}
 89	slices.SortFunc(matched, func(a, b Command) int { return strings.Compare(joinPath(a.Path), joinPath(b.Path)) })
 90	entries := make([]helpEntry, len(matched))
 91	for i, cmd := range matched {
 92		entries[i] = helpEntry{Path: joinPath(cmd.Path), Summary: cmd.Summary, Usage: cmd.Usage, Flags: cmd.Flags, Examples: cmd.Examples}
 93	}
 94	return c.emit(entries, func(w io.Writer) {
 95		switch {
 96		case prefix == "":
 97			for _, e := range entries {
 98				summary := e.Summary
 99				if c.Term.Cols > 0 {
100					if avail := c.Term.Cols - max(cells(e.Path), 24) - 1; avail > 0 {
101						summary = clip(summary, avail)
102					}
103				}
104				fmt.Fprintf(w, "%-24s %s\n", e.Path, summary)
105			}
106		case joinPath(matched[0].Path) == prefix:
107			c.helpVerb(w, matched[0], matched[1:])
108		default:
109			c.helpNoun(w, prefix, matched)
110		}
111	})
112}
113
114// program is how help spells the command it documents: the CLI at a
115// terminal (only the CLI or a caller passing --term sets one), ssh
116// otherwise.
117func (c *Ctx) program() string {
118	if c.Term.Cols > 0 {
119		return "gitbay"
120	}
121	return "ssh git@" + hostOf(c.Cfg.Server.SiteURL)
122}
123
124func (c *Ctx) heading(w io.Writer, s string) {
125	fmt.Fprintln(w, c.Term.paint(sgrBold, s))
126}
127
128// wrapLine prints prefix+text, wrapping text at Term.Cols with a hanging
129// indent under prefix when it would overflow. Plain output never wraps.
130func (c *Ctx) wrapLine(w io.Writer, prefix, text string) {
131	if c.Term.Cols <= 0 || cells(prefix+text) <= c.Term.Cols {
132		fmt.Fprintln(w, prefix+text)
133		return
134	}
135	indent := cells(prefix)
136	width := c.Term.Cols - indent
137	for i, line := range termtext.Wrap(text, width) {
138		if i == 0 {
139			fmt.Fprintln(w, prefix+line)
140		} else {
141			fmt.Fprintln(w, strings.Repeat(" ", indent)+line)
142		}
143	}
144}
145
146func (c *Ctx) helpVerb(w io.Writer, cmd Command, below []Command) {
147	fmt.Fprintln(w, cmd.Summary)
148	fmt.Fprintln(w)
149	c.heading(w, "USAGE")
150	// Cutting at the first optional flag drops the rest of the usage
151	// syntax behind "[flags]" — safe only for what is actually optional.
152	// A required flag (repo delete --yes) or an alternative
153	// (notifications read <id>... | --all) has no " [--" to cut at, so
154	// the usage prints whole.
155	shape := cmd.Usage
156	if i := strings.Index(shape, " [--"); i >= 0 {
157		shape = shape[:i] + " [flags]"
158	}
159	fmt.Fprintf(w, "  %s %s\n", c.program(), shape)
160	fmt.Fprintln(w)
161	c.heading(w, "FLAGS")
162	rows := make([][2]string, 0, len(cmd.Flags)+1)
163	for _, f := range cmd.Flags {
164		name := f.Name
165		if f.Arg != "" {
166			name += " " + f.Arg
167		}
168		desc := f.Desc
169		if f.Default != "" {
170			desc += " (default " + f.Default + ")"
171		}
172		rows = append(rows, [2]string{name, desc})
173	}
174	rows = append(rows, [2]string{"--json", "machine-readable output"})
175	wide := 0
176	for _, r := range rows {
177		wide = max(wide, cells(r[0]))
178	}
179	for _, r := range rows {
180		c.wrapLine(w, "  "+pad(r[0], wide)+"  ", r[1])
181	}
182	if len(cmd.Examples) > 0 {
183		fmt.Fprintln(w)
184		c.heading(w, "EXAMPLES")
185		for _, ex := range cmd.Examples {
186			c.wrapLine(w, "  "+c.program()+" ", ex)
187		}
188	}
189	if len(below) > 0 {
190		fmt.Fprintln(w)
191		c.heading(w, "SEE ALSO")
192		for _, b := range below {
193			fmt.Fprintf(w, "  %s %s\n", c.program(), joinPath(b.Path))
194		}
195	}
196}
197
198func (c *Ctx) helpNoun(w io.Writer, prefix string, cmds []Command) {
199	head := nounSummaries[strings.Fields(prefix)[0]]
200	fmt.Fprintln(w, head)
201	fmt.Fprintln(w)
202	c.heading(w, "USAGE")
203	fmt.Fprintf(w, "  %s %s <verb> ...\n", c.program(), prefix)
204	wide := 0
205	for _, cmd := range cmds {
206		wide = max(wide, cells(strings.TrimPrefix(joinPath(cmd.Path), prefix+" ")))
207	}
208	for _, section := range []struct {
209		title string
210		read  bool
211	}{{"READ", true}, {"WRITE", false}} {
212		first := true
213		for _, cmd := range cmds {
214			if cmd.ReadOnly != section.read {
215				continue
216			}
217			if first {
218				fmt.Fprintln(w)
219				c.heading(w, section.title)
220				first = false
221			}
222			verb := strings.TrimPrefix(joinPath(cmd.Path), prefix+" ")
223			fmt.Fprintf(w, "  %s  %s\n", pad(verb, wide), cmd.Summary)
224		}
225	}
226	fmt.Fprintln(w)
227	fmt.Fprintf(w, "%s %s <verb> --help for flags.\n", c.program(), prefix)
228}