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}