Skip to content

Commit 34ce5fc

Browse files
committed
feat: provide ability to add version input to commands
Right now, I can't see why this library should provide a way to version subcommands, so this is just intended to be used for the root program commands to output some vcs / go version info.
1 parent c062837 commit 34ce5fc

File tree

4 files changed

+182
-14
lines changed

4 files changed

+182
-14
lines changed

builder.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ var DefaultHelpInput = NewBoolOpt("help").
1111
Help("Show this help message and exit.").
1212
WithHelpGen(DefaultHelpGenerator)
1313

14+
var DefaultVersionOpt = NewVersionOpt('v', "version", VersionOptConfig{
15+
HelpBlurb: "Print the build info version and exit",
16+
})
17+
1418
var (
1519
errMixingPosArgsAndSubcmds = "commands cannot have both positional args and subcommands"
1620
errEmptyCmdName = "empty command name"
@@ -315,6 +319,59 @@ func (in InputInfo) WithHelpGen(hg HelpGenerator) InputInfo {
315319
return in
316320
}
317321

322+
// WithVersioner will set this input's Versioner to the given [Versioner]. This will turn
323+
// this input into one that, similar to help inputs, causes the parsing to return a
324+
// [HelpOrVersionRequested] error. See [NewVersionOpt] for a convenient way to create
325+
// version inputs.
326+
func (in InputInfo) WithVersioner(ver Versioner) InputInfo {
327+
in.Versioner = ver
328+
return in
329+
}
330+
331+
// VersionOptConfig is used to pass customization values to [NewVersionOpt].
332+
type VersionOptConfig struct {
333+
HelpBlurb string
334+
IncludeGoVersion bool
335+
}
336+
337+
// NewVersionOpt returns a input that will the Versioner field set to a function that
338+
// outputs information based on the given configuration values. At a minimum, the default
339+
// version message will always contain the Go module version obtained from
340+
// [debug.BuildInfo]. Version inputs, similar to help inputs, cause this library's
341+
// parsing to return a [HelpOrVersionRequested] error. This function will panic if the
342+
// given long name is empty and the given short name is either 0 or '-'.
343+
func NewVersionOpt(short byte, long string, cfg VersionOptConfig) InputInfo {
344+
if cfg.HelpBlurb == "" {
345+
cfg.HelpBlurb = "Print version info and exit."
346+
}
347+
348+
hasShort := short != 0 && short != '-'
349+
350+
id := long
351+
if id == "" {
352+
if !hasShort {
353+
panic("must provide at least either a long or short name for the version option")
354+
}
355+
id = string(short)
356+
}
357+
358+
in := NewBoolOpt(id).Help(cfg.HelpBlurb)
359+
if hasShort && long != "" {
360+
in = in.Short(short)
361+
}
362+
return in.WithVersioner(func(_ Input) string {
363+
bi, ok := debug.ReadBuildInfo()
364+
if !ok {
365+
Fatal(1, "unable to read build info")
366+
}
367+
ver := bi.Main.Version + "\n"
368+
if cfg.IncludeGoVersion {
369+
ver += bi.GoVersion + "\n"
370+
}
371+
return ver
372+
})
373+
}
374+
318375
func (in *InputInfo) isOption() bool {
319376
return in.NameShort != 0 || in.NameLong != ""
320377
}

builder_test.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,17 @@ func TestBuilder(t *testing.T) {
206206
"command 'root subcmd' contains duplicate subcommand name 'aa'",
207207
},
208208
},
209+
{
210+
name: "using NewVersionOpt with neither a short or long name",
211+
builds: []func(){
212+
func() { NewCmd("root").Opt(NewVersionOpt(0, "", VersionOptConfig{})) },
213+
func() { NewCmd("root").Opt(NewVersionOpt('-', "", VersionOptConfig{})) },
214+
},
215+
expPanicVals: []any{
216+
"must provide at least either a long or short name for the version option",
217+
"must provide at least either a long or short name for the version option",
218+
},
219+
},
209220
} {
210221
t.Run("prevent "+tt.name, func(t *testing.T) {
211222
for i, build := range tt.builds {

cli.go

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,8 @@ type InputInfo struct {
121121
ValueName string
122122
ValueParser ValueParser
123123

124-
HelpGen HelpGenerator
124+
HelpGen HelpGenerator
125+
Versioner Versioner
125126
}
126127

127128
// ValueParser describes any function that takes a string and returns some type or an
@@ -134,6 +135,10 @@ type ValueParser = func(string) (any, error)
134135
// [DefaultHelpGenerator] for an example.
135136
type HelpGenerator = func(Input, *CommandInfo) string
136137

138+
// Versioner describes any function that will return a version string based on
139+
// the [Input] that triggered it. See [DefaultHelpGenerator] for an example.
140+
type Versioner = func(Input) string
141+
137142
// Command is a parsed command structure.
138143
type Command struct {
139144
Name string
@@ -257,8 +262,8 @@ func (in CommandInfo) ParseOrExit() *Command {
257262
func (in CommandInfo) ParseTheseOrExit(args ...string) *Command {
258263
c, err := in.ParseThese(args...)
259264
if err != nil {
260-
if e, ok := err.(HelpRequestError); ok {
261-
fmt.Print(e.HelpMsg)
265+
if e, ok := err.(HelpOrVersionRequested); ok {
266+
fmt.Print(e.Msg)
262267
os.Exit(0)
263268
} else {
264269
Fatal(1, err)
@@ -285,12 +290,12 @@ func (in *CommandInfo) ParseThese(args ...string) (*Command, error) {
285290
return c, err
286291
}
287292

288-
type HelpRequestError struct {
289-
HelpMsg string
293+
type HelpOrVersionRequested struct {
294+
Msg string
290295
}
291296

292-
func (h HelpRequestError) Error() string {
293-
return h.HelpMsg
297+
func (h HelpOrVersionRequested) Error() string {
298+
return h.Msg
294299
}
295300

296301
func lookupOptionByShortName(in *CommandInfo, shortName byte) *InputInfo {
@@ -425,8 +430,13 @@ func parse(c *CommandInfo, p *Command, args []string) error {
425430
}
426431

427432
if optInfo.HelpGen != nil {
428-
return HelpRequestError{
429-
HelpMsg: optInfo.HelpGen(pi, c),
433+
return HelpOrVersionRequested{
434+
Msg: optInfo.HelpGen(pi, c),
435+
}
436+
}
437+
if optInfo.Versioner != nil {
438+
return HelpOrVersionRequested{
439+
Msg: optInfo.Versioner(pi),
430440
}
431441
}
432442

@@ -493,8 +503,13 @@ func parse(c *CommandInfo, p *Command, args []string) error {
493503
}
494504

495505
if optInfo.HelpGen != nil {
496-
return HelpRequestError{
497-
HelpMsg: optInfo.HelpGen(pi, c),
506+
return HelpOrVersionRequested{
507+
Msg: optInfo.HelpGen(pi, c),
508+
}
509+
}
510+
if optInfo.Versioner != nil {
511+
return HelpOrVersionRequested{
512+
Msg: optInfo.Versioner(pi),
498513
}
499514
}
500515

@@ -586,7 +601,7 @@ func parse(c *CommandInfo, p *Command, args []string) error {
586601
// as no subcommand has requested a help message.
587602
errFromSubcmd := parse(subcmdInfo, p.Subcmd, rest[1:])
588603
if errMissingOpts != nil {
589-
if _, ok := errFromSubcmd.(HelpRequestError); !ok {
604+
if _, ok := errFromSubcmd.(HelpOrVersionRequested); !ok {
590605
return errMissingOpts
591606
}
592607
}

cli_test.go

Lines changed: 87 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -388,8 +388,8 @@ func TestParsing(t *testing.T) {
388388
{
389389
Case: ttCase(),
390390
args: []string{"one", "-h"},
391-
expErr: HelpRequestError{
392-
HelpMsg: "cmd one\n\nusage:\n one [options]\n\noptions:\n --cc <arg> (required)\n -h, --help Show this help message and exit.\n",
391+
expErr: HelpOrVersionRequested{
392+
Msg: "cmd one\n\nusage:\n one [options]\n\noptions:\n --cc <arg> (required)\n -h, --help Show this help message and exit.\n",
393393
},
394394
},
395395
{
@@ -524,6 +524,91 @@ func TestParsing(t *testing.T) {
524524
},
525525
},
526526
},
527+
// versioning (on just the top level for now)
528+
// using the builder method for a custom versioner
529+
{
530+
name: "versioning",
531+
cmd: NewCmd("cmd").
532+
Opt(NewOpt("a")).
533+
Opt(NewBoolOpt("b")).
534+
Opt(NewBoolOpt("v").WithVersioner(func(_ Input) string { return "version-string" })),
535+
variations: []testInputOutput{
536+
{
537+
Case: ttCase(),
538+
args: []string{"-v"},
539+
expErr: HelpOrVersionRequested{
540+
Msg: "version-string",
541+
},
542+
},
543+
{
544+
Case: ttCase(),
545+
args: []string{"-a", "A"},
546+
expected: Command{
547+
Inputs: []Input{
548+
{ID: "a", From: ParsedFrom{Opt: "a"}, RawValue: "A", Value: "A"},
549+
},
550+
},
551+
},
552+
{
553+
Case: ttCase(),
554+
args: []string{"-bv"},
555+
expErr: HelpOrVersionRequested{
556+
Msg: "version-string",
557+
},
558+
},
559+
},
560+
},
561+
// versioning (on just the top level for now)
562+
// using the constructor for a version option
563+
{
564+
name: "versioning",
565+
cmd: NewCmd("cmd").Opt(DefaultVersionOpt),
566+
variations: []testInputOutput{
567+
{
568+
Case: ttCase(),
569+
args: []string{"--version"},
570+
expErr: HelpOrVersionRequested{Msg: "(devel)\n"},
571+
},
572+
{
573+
Case: ttCase(),
574+
args: []string{"-v"},
575+
expErr: HelpOrVersionRequested{Msg: "(devel)\n"},
576+
},
577+
},
578+
},
579+
// versioning (on just the top level for now)
580+
// using the constructor for a version option but leaving out a short name
581+
{
582+
name: "versioning",
583+
cmd: NewCmd("cmd").Opt(NewVersionOpt(0, "version", VersionOptConfig{})),
584+
variations: []testInputOutput{
585+
{
586+
Case: ttCase(),
587+
args: []string{"--version"},
588+
expErr: HelpOrVersionRequested{Msg: "(devel)\n"},
589+
},
590+
{Case: ttCase(), args: []string{"-v"}, expErr: UnknownOptionError{Name: "-v"}},
591+
{Case: ttCase(), args: []string{"-V"}, expErr: UnknownOptionError{Name: "-V"}},
592+
},
593+
},
594+
// versioning (on just the top level for now)
595+
// using the constructor for a version option but leaving out a long name
596+
{
597+
name: "versioning",
598+
cmd: NewCmd("cmd").Opt(NewVersionOpt('Z', "", VersionOptConfig{})),
599+
variations: []testInputOutput{
600+
{
601+
Case: ttCase(),
602+
args: []string{"-Z"},
603+
expErr: HelpOrVersionRequested{Msg: "(devel)\n"},
604+
},
605+
{
606+
Case: ttCase(),
607+
args: []string{"--version"},
608+
expErr: UnknownOptionError{Name: "--version"},
609+
},
610+
},
611+
},
527612
} {
528613
for tioIdx, tio := range tt.variations {
529614
t.Run(fmt.Sprintf("%s %d", tt.name, tioIdx), func(t *testing.T) {

0 commit comments

Comments
 (0)