diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..600ad7f --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,34 @@ +name: goreleaser + +on: + push: + tags: + - '*' + +permissions: { } + +jobs: + goreleaser: + runs-on: ubuntu-latest + permissions: + contents: write + id-token: write + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + fetch-depth: 0 + persist-credentials: false + - run: git fetch --force --tags + - uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b + with: + go-version: '>=1.24.4' + cache: false + - name: Install cosign + uses: sigstore/cosign-installer@3454372f43399081ed03b604cb2d021dabca52bb + - uses: goreleaser/goreleaser-action@90a3faa9d0182683851fbfa97ca1a2cb983bfca3 + with: + distribution: goreleaser + version: latest + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.golangci.yaml b/.golangci.yaml new file mode 100644 index 0000000..ba036cc --- /dev/null +++ b/.golangci.yaml @@ -0,0 +1,35 @@ +version: "2" +linters: + default: all + disable: + - cyclop + - tagliatelle + - testpackage + - exhaustruct + settings: + funlen: + lines: 70 + depguard: + rules: + main: + allow: + - $gostd + - github.com/keenbytes/broccli/v3 + exclusions: + generated: disable + rules: + - linters: + - err113 + - exhaustruct + - funlen + - varnamelen + - dupl + path: _test.go +formatters: + enable: + - gci + - gofmt + - gofumpt + - goimports + - golines + diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..c4a1ec5 --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,34 @@ +# yaml-language-server: $schema=https://goreleaser.com/static/schema.json +# vim: set ts=2 sw=2 tw=0 fo=cnqoj + +version: 2 + +before: + hooks: + - go mod tidy + +checksum: + name_template: 'checksums.txt' + +snapshot: + version_template: "{{ incpatch .Version }}-next" + +changelog: + sort: asc + filters: + exclude: + - "^docs:" + - "^test:" + +#signs: +# - id: archive-keyless +# cmd: cosign +# artifacts: archive +# signature: ${artifact}.sigstore +# output: false +# args: +# - "sign-blob" +# - "--bundle=${signature}" +# - "${artifact}" +# - "-y" + diff --git a/cli.go b/cli.go index d2c4d6f..465d320 100644 --- a/cli.go +++ b/cli.go @@ -9,6 +9,7 @@ import ( "path" "reflect" "sort" + "strings" "text/tabwriter" ) @@ -18,44 +19,50 @@ import ( // variables. type Broccli struct { name string - usage string + usage string author string - commands map[string]*Command - env map[string]*param + commands map[string]*Command + env map[string]*param parsedFlags map[string]string parsedArgs map[string]string } // NewBroccli returns pointer to a new Broccli instance. Name, usage and author are displayed on the syntax screen. func NewBroccli(name, usage, author string) *Broccli { - c := &Broccli{ + cli := &Broccli{ name: name, - usage: usage, + usage: usage, author: author, - commands: map[string]*Command{}, - env: map[string]*param{}, + commands: map[string]*Command{}, + env: map[string]*param{}, parsedFlags: map[string]string{}, parsedArgs: map[string]string{}, } - return c + + return cli } // Command returns pointer to a new command with specified name, usage and handler. Handler is a function that // gets called when command is executed. // Additionally, there is a set of options that can be passed as arguments. Search for commandOption for more info. -func (c *Broccli) Command(name, usage string, handler func(ctx context.Context, cli *Broccli) int, opts ...commandOption) *Command { +func (c *Broccli) Command( + name, usage string, + handler func(ctx context.Context, cli *Broccli) int, + opts ...CommandOption, +) *Command { c.commands[name] = &Command{ name: name, - usage: usage, + usage: usage, flags: map[string]*param{}, args: map[string]*param{}, - env: map[string]*param{}, + env: map[string]*param{}, handler: handler, options: commandOptions{}, } - for _, o := range opts { - o(&(c.commands[name].options)) + for _, opt := range opts { + opt(&(c.commands[name].options)) } + return c.commands[name] } @@ -64,7 +71,7 @@ func (c *Broccli) Command(name, usage string, handler func(ctx context.Context, func (c *Broccli) Env(name string, usage string) { c.env[name] = ¶m{ name: name, - usage: usage, + usage: usage, flags: IsRequired, options: paramOptions{}, } @@ -87,89 +94,146 @@ func (c *Broccli) Run(ctx context.Context) int { // display help, first arg is binary filename if len(os.Args) < 2 || os.Args[1] == "-h" || os.Args[1] == "--help" { c.printHelp() + return 0 } - for _, n := range c.sortedCommands() { - if n != os.Args[1] { + + for _, commandName := range c.sortedCommands() { + if commandName != os.Args[1] { continue } // display command help if len(os.Args) > 2 && (os.Args[2] == "-h" || os.Args[2] == "--help") { - c.commands[n].printHelp() + c.commands[commandName].printHelp() + return 0 } // check required environment variables if len(c.env) > 0 { for env, param := range c.env { - v := os.Getenv(env) - param.flags = param.flags | IsRequired - err := param.validateValue(v) + envValue := os.Getenv(env) + param.flags |= IsRequired + + err := param.validateValue(envValue) if err != nil { - fmt.Fprintf(os.Stderr, "ERROR: %s %s: %s\n", c.getParamTypeName(ParamEnvVar), param.name, err.Error()) + fmt.Fprintf( + os.Stderr, + "ERROR: %s %s: %s\n", + c.getParamTypeName(ParamEnvVar), + param.name, + err.Error(), + ) c.printHelp() + return 1 } } } // parse and validate all the flags and args - exitCode := c.parseFlags(c.commands[n]) + exitCode := c.parseFlags(c.commands[commandName]) if exitCode > 0 { return exitCode } - return c.commands[n].handler(ctx, c) + return c.commands[commandName].handler(ctx, c) } // command not found c.printInvalidCommand(os.Args[1]) + return 1 } func (c *Broccli) sortedCommands() []string { - cmds := reflect.ValueOf(c.commands).MapKeys() - scmds := make([]string, len(cmds)) - for i, cmd := range cmds { - scmds[i] = cmd.String() + commandNames := reflect.ValueOf(c.commands).MapKeys() + + commandNamesSorted := make([]string, len(commandNames)) + + for i, cmd := range commandNames { + commandNamesSorted[i] = cmd.String() } - sort.Strings(scmds) - return scmds + + sort.Strings(commandNamesSorted) + + return commandNamesSorted } func (c *Broccli) sortedEnv() []string { - evs := reflect.ValueOf(c.env).MapKeys() - sevs := make([]string, len(evs)) - for i, ev := range evs { - sevs[i] = ev.String() + envNames := reflect.ValueOf(c.env).MapKeys() + + envNamesSorted := make([]string, len(envNames)) + + for i, ev := range envNames { + envNamesSorted[i] = ev.String() } - sort.Strings(sevs) - return sevs + + sort.Strings(envNamesSorted) + + return envNamesSorted } func (c *Broccli) printHelp() { - fmt.Fprintf(os.Stdout, "%s by %s\n%s\n\n", c.name, c.author, c.usage) - fmt.Fprintf(os.Stdout, "Usage: %s COMMAND\n\n", path.Base(os.Args[0])) + var helpMessage strings.Builder + + _, _ = fmt.Fprintf( + &helpMessage, + "%s by %s\n%s\n\nUsage: %s COMMAND\n\n", + c.name, + c.author, + c.usage, + path.Base(os.Args[0]), + ) if len(c.env) > 0 { - fmt.Fprintf(os.Stdout, "Required environment variables:\n") - w := new(tabwriter.Writer) - w.Init(os.Stdout, 8, 8, 0, '\t', 0) + _, _ = fmt.Fprintf(&helpMessage, "Required environment variables:\n") + + tabFormatter := new(tabwriter.Writer) + tabFormatter.Init( + &helpMessage, + tabWriterMinWidth, + tabWriterTabWidth, + tabWriterPadding, + tabWriterPadChar, + 0, + ) + for _, n := range c.sortedEnv() { - fmt.Fprintf(w, "%s\t%s\n", n, c.env[n].usage) + _, _ = fmt.Fprintf(tabFormatter, "%s\t%s\n", n, c.env[n].usage) } - w.Flush() + + _ = tabFormatter.Flush() } - fmt.Fprintf(os.Stdout, "Commands:\n") - w := new(tabwriter.Writer) - w.Init(os.Stdout, 10, 8, 0, '\t', 0) - for _, n := range c.sortedCommands() { - fmt.Fprintf(w, " %s\t%s\n", n, c.commands[n].usage) + _, _ = fmt.Fprintf(&helpMessage, "Commands:\n") + + tabFormatter := new(tabwriter.Writer) + tabFormatter.Init( + &helpMessage, + tabWriterMinWidthForCommand, + tabWriterTabWidth, + tabWriterPadding, + tabWriterPadChar, + 0, + ) + + for _, commandName := range c.sortedCommands() { + _, _ = fmt.Fprintf(&helpMessage, " %s\t%s\n", commandName, c.commands[commandName].usage) } - w.Flush() - fmt.Fprintf(os.Stdout, "\nRun '%s COMMAND --help' for command syntax.\n", path.Base(os.Args[0])) + _ = tabFormatter.Flush() + + _, _ = fmt.Fprintf( + &helpMessage, + "\nRun '%s COMMAND --help' for command syntax.\n", + path.Base(os.Args[0]), + ) + + _, err := fmt.Fprint(os.Stdout, helpMessage.String()) + if err != nil { + fmt.Fprintf(os.Stderr, "ERROR: Unable to build help message") + } } func (c *Broccli) printInvalidCommand(cmd string) { @@ -178,27 +242,35 @@ func (c *Broccli) printInvalidCommand(cmd string) { } // getFlagSetPtrs creates flagset instance, parses flags and returns list of pointers to results of parsing the flags. -func (c *Broccli) getFlagSetPtrs(cmd *Command) (map[string]interface{}, map[string]interface{}, []string) { +func (c *Broccli) getFlagSetPtrs( + cmd *Command, +) (map[string]interface{}, map[string]interface{}, []string) { fset := flag.NewFlagSet("flagset", flag.ContinueOnError) // nothing should come out of flagset fset.Usage = func() {} fset.SetOutput(io.Discard) - nameFlags := make(map[string]interface{}) - aliasFlags := make(map[string]interface{}) - fs := cmd.sortedFlags() - for _, n := range fs { - f := cmd.flags[n] - if f.valueType == TypeBool { - nameFlags[n] = fset.Bool(n, false, "") - aliasFlags[f.alias] = fset.Bool(f.alias, false, "") + flagNamePtrs := make(map[string]interface{}) + flagAliasPtrs := make(map[string]interface{}) + + flagNamesSorted := cmd.sortedFlags() + for _, flagName := range flagNamesSorted { + flagInstance := cmd.flags[flagName] + if flagInstance.valueType == TypeBool { + flagNamePtrs[flagName] = fset.Bool(flagName, false, "") + flagAliasPtrs[flagInstance.alias] = fset.Bool(flagInstance.alias, false, "") } else { - nameFlags[n] = fset.String(n, "", "") - aliasFlags[f.alias] = fset.String(f.alias, "", "") + flagNamePtrs[flagName] = fset.String(flagName, "", "") + flagAliasPtrs[flagInstance.alias] = fset.String(flagInstance.alias, "", "") } } - fset.Parse(os.Args[2:]) - return nameFlags, aliasFlags, fset.Args() + + err := fset.Parse(os.Args[2:]) + if err != nil { + fmt.Fprintf(os.Stderr, "ERROR: Unable to parse flags: %s", err.Error()) + } + + return flagNamePtrs, flagAliasPtrs, fset.Args() } func (c *Broccli) checkEnv(cmd *Command) int { @@ -206,13 +278,21 @@ func (c *Broccli) checkEnv(cmd *Command) int { return 0 } - for env, envVar := range cmd.env { - v := os.Getenv(env) - envVar.flags = envVar.flags | IsRequired - err := envVar.validateValue(v) + for envName, envVar := range cmd.env { + envValue := os.Getenv(envName) + envVar.flags |= IsRequired + + err := envVar.validateValue(envValue) if err != nil { - fmt.Fprintf(os.Stderr, "ERROR: %s %s: %s\n", c.getParamTypeName(ParamEnvVar), envVar.name, err.Error()) + fmt.Fprintf( + os.Stderr, + "ERROR: %s %s: %s\n", + c.getParamTypeName(ParamEnvVar), + envVar.name, + err.Error(), + ) cmd.printHelp() + return 1 } } @@ -220,8 +300,13 @@ func (c *Broccli) checkEnv(cmd *Command) int { return 0 } -func (c *Broccli) processOnTrue(cmd *Command, fs []string, nflags map[string]interface{}, aflags map[string]interface{}) { - for _, name := range fs { +func (c *Broccli) processOnTrue( + cmd *Command, + flagNames []string, + nflags map[string]interface{}, + aflags map[string]interface{}, +) { + for _, name := range flagNames { if cmd.flags[name].valueType != TypeBool { continue } @@ -231,63 +316,90 @@ func (c *Broccli) processOnTrue(cmd *Command, fs []string, nflags map[string]int } // OnTrue is called when a flag is true + //nolint:forcetypeassert if *(nflags[name]).(*bool) || *(aflags[cmd.flags[name].alias]).(*bool) { cmd.flags[name].options.onTrue(cmd) } } } -func (c *Broccli) processFlags(cmd *Command, fs []string, nflags map[string]interface{}, aflags map[string]interface{}) int { - for _, name := range fs { +func (c *Broccli) processFlags( + cmd *Command, + flagNames []string, + nflags map[string]interface{}, + aflags map[string]interface{}, +) int { + for _, name := range flagNames { flag := cmd.flags[name] if flag.valueType == TypeBool { c.parsedFlags[name] = "false" + //nolint:forcetypeassert if *(nflags[name]).(*bool) || *(aflags[cmd.flags[name].alias]).(*bool) { c.parsedFlags[name] = "true" } + continue } + //nolint:forcetypeassert aliasValue := *(aflags[flag.alias]).(*string) + //nolint:forcetypeassert nameValue := *(nflags[name]).(*string) + if nameValue != "" && aliasValue != "" { fmt.Fprintf(os.Stderr, "ERROR: Both -%s and --%s passed", flag.alias, flag.name) + return 1 } - v := aliasValue + + flagValue := aliasValue if nameValue != "" { - v = nameValue + flagValue = nameValue } - err := flag.validateValue(v) + err := flag.validateValue(flagValue) if err != nil { - fmt.Fprintf(os.Stderr, "ERROR: %s %s: %s\n", c.getParamTypeName(ParamFlag), name, err.Error()) + fmt.Fprintf( + os.Stderr, + "ERROR: %s %s: %s\n", + c.getParamTypeName(ParamFlag), + name, + err.Error(), + ) cmd.printHelp() + return 1 } - c.parsedFlags[name] = v + c.parsedFlags[name] = flagValue } return 0 } -func (c *Broccli) processArgs(cmd *Command, as []string, args []string) int { - for i, n := range as { - v := "" - if len(args) >= i+1 { - v = args[i] +func (c *Broccli) processArgs(cmd *Command, argNamesSorted []string, args []string) int { + for argIdx, argName := range argNamesSorted { + argValue := "" + if len(args) >= argIdx+1 { + argValue = args[argIdx] } - err := cmd.args[n].validateValue(v) + err := cmd.args[argName].validateValue(argValue) if err != nil { - fmt.Fprintf(os.Stderr, "ERROR: %s %s: %s\n", c.getParamTypeName(ParamArg), cmd.args[n].valuePlaceholder, err.Error()) + fmt.Fprintf( + os.Stderr, + "ERROR: %s %s: %s\n", + c.getParamTypeName(ParamArg), + cmd.args[argName].valuePlaceholder, + err.Error(), + ) cmd.printHelp() + return 1 } - c.parsedArgs[n] = v + c.parsedArgs[argName] = argValue } return 0 @@ -302,6 +414,7 @@ func (c *Broccli) processOnPostValidation(cmd *Command) int { if err != nil { fmt.Fprintf(os.Stderr, "ERROR: %s\n", err.Error()) cmd.printHelp() + return 1 } @@ -314,20 +427,20 @@ func (c *Broccli) parseFlags(cmd *Command) int { return exitCode } - fs := cmd.sortedFlags() - nameFlags, aliasFlags, args := c.getFlagSetPtrs(cmd) + flags := cmd.sortedFlags() + flagNamePtrs, flagAliasPtrs, args := c.getFlagSetPtrs(cmd) // Loop through boolean flags and execute onTrue() hook if exists. That function might be used to change behaviour // of other flags, eg. when -e is added, another flag or argument might become required (or obsolete). // Bool fields will be parsed out in this loop so no reason to process them again in the next one. - c.processOnTrue(cmd, fs, nameFlags, aliasFlags) + c.processOnTrue(cmd, flags, flagNamePtrs, flagAliasPtrs) - if exitCode := c.processFlags(cmd, fs, nameFlags, aliasFlags); exitCode != 0 { + if exitCode := c.processFlags(cmd, flags, flagNamePtrs, flagAliasPtrs); exitCode != 0 { return exitCode } - as := cmd.sortedArgs() - if exitCode := c.processArgs(cmd, as, args); exitCode != 0 { + argsNamesSorted := cmd.sortedArgs() + if exitCode := c.processArgs(cmd, argsNamesSorted, args); exitCode != 0 { return exitCode } @@ -342,8 +455,10 @@ func (c *Broccli) getParamTypeName(t int8) string { if t == ParamArg { return "Argument" } + if t == ParamEnvVar { return "Env var" } + return "Flag" } diff --git a/cli_test.go b/cli_test.go index 24465c0..57d7b2b 100644 --- a/cli_test.go +++ b/cli_test.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "io" - "log" "os" "strings" "testing" @@ -13,66 +12,98 @@ import ( // TestCLI creates a test CLI instance with a single command with flags and test // basic functionality. func TestCLI(t *testing.T) { - f, err := os.CreateTemp("", "stdout") + t.Parallel() + + f, err := os.CreateTemp(t.TempDir(), "stdout") if err != nil { - log.Fatal(err) + t.Error("error creating temporary file") } - defer os.Remove(f.Name()) - devNull, err := os.OpenFile("/dev/null", os.O_APPEND, 644) + defer func() { + err := os.Remove(f.Name()) + if err != nil { + t.Error("error removing temporary file") + } + }() + + devNull, err := os.OpenFile("/dev/null", os.O_APPEND, 0o600) if err != nil { - log.Fatal(err) + t.Error("error opening temporary file") } + os.Stdout = devNull os.Stderr = devNull c := NewBroccli("Example", "App", "Author ") - cmd1 := c.Command("cmd1", "Prints out a string", func(ctx context.Context, c *Broccli) int { - fmt.Fprintf(f, "TESTVALUE:%s%s\n\n", c.Flag("tekst"), c.Flag("alphanumdots")) + cmd1 := c.Command("cmd1", "Prints out a string", func(_ context.Context, c *Broccli) int { + _, _ = fmt.Fprintf(f, "TESTVALUE:%s%s\n\n", c.Flag("tekst"), c.Flag("alphanumdots")) + if c.Flag("bool") == "true" { - fmt.Fprintf(f, "BOOL:true") + _, _ = fmt.Fprintf(f, "BOOL:true") } + return 2 }) cmd1.Flag("tekst", "t", "Text", "Text to print", TypeString, IsRequired) - cmd1.Flag("alphanumdots", "a", "Alphanum with dots", "Can have dots", TypeAlphanumeric, AllowDots) - cmd1.Flag("make-required", "r", "", "Make alphanumdots required", TypeBool, 0, OnTrue(func(c *Command) { - c.flags["alphanumdots"].flags = c.flags["alphanumdots"].flags | IsRequired - })) + cmd1.Flag( + "alphanumdots", + "a", + "Alphanum with dots", + "Can have dots", + TypeAlphanumeric, + AllowDots, + ) + cmd1.Flag( + "make-required", + "r", + "", + "Make alphanumdots required", + TypeBool, + 0, + OnTrue(func(c *Command) { + c.flags["alphanumdots"].flags |= IsRequired + }), + ) // Boolean should work fine even when the optional OnTrue is not passed cmd1.Flag("bool", "b", "", "Bool value", TypeBool, 0) os.Args = []string{"test", "cmd1"} + got := c.Run(context.Background()) if got != 1 { t.Errorf("CLI.Run() should have returned 1 instead of %d", got) } os.Args = []string{"test", "cmd1", "-t", ""} + got = c.Run(context.Background()) if got != 1 { t.Errorf("CLI.Run() should have returned 1 instead of %d", got) } os.Args = []string{"test", "cmd1", "--tekst", "Tekst123", "--alphanumdots"} + got = c.Run(context.Background()) if got != 2 { t.Errorf("CLI.Run() should have returned 2 instead of %d", got) } os.Args = []string{"test", "cmd1", "--tekst", "Tekst123", "-r"} + got = c.Run(context.Background()) if got != 1 { t.Errorf("CLI.Run() should have returned 1 instead of %d", got) } os.Args = []string{"test", "cmd1", "--tekst", "Tekst123", "--alphanumdots", "aZ0-9"} + got = c.Run(context.Background()) if got != 1 { t.Errorf("CLI.Run() should have returned 1 instead of %d", got) } os.Args = []string{"test", "cmd1", "--tekst", "Tekst123", "--alphanumdots", "aZ0.9", "-b"} + got = c.Run(context.Background()) if got != 2 { t.Errorf("CLI.Run() should have returned 2 instead of %d", got) @@ -80,17 +111,25 @@ func TestCLI(t *testing.T) { f2, err := os.Open(f.Name()) if err != nil { - log.Fatal(err) + t.Error("error opening temporary file") } - defer f2.Close() + + defer func() { + err := f2.Close() + if err != nil { + t.Error("error closing temporary file") + } + }() + b, err := io.ReadAll(f2) if err != nil { - log.Fatal(err) + t.Error("error reading output file contents") } if !strings.Contains(string(b), "TESTVALUE:Tekst123aZ0.9") { t.Errorf("Cmd handler failed to work") } + if !strings.Contains(string(b), "BOOL:true") { t.Errorf("Cmd handler failed to work") } diff --git a/cmd.go b/cmd.go index 10130bf..1c7d818 100644 --- a/cmd.go +++ b/cmd.go @@ -8,6 +8,7 @@ import ( "path" "reflect" "sort" + "strings" "text/tabwriter" ) @@ -15,12 +16,12 @@ import ( // Such command can have flags and arguments. In addition to that, required environment variables can be set. type Command struct { name string - usage string + usage string flags map[string]*param args map[string]*param argsOrder []string argsIdx int - env map[string]*param + env map[string]*param handler func(context.Context, *Broccli) int options commandOptions } @@ -29,14 +30,19 @@ type Command struct { // Method requires name (eg. 'data' for '--data', alias (eg. 'd' for '-d'), placeholder for the value displayed on the // 'help' screen, usage, type of the value and additional validation that is set up with bit flags, eg. IsRequired // or AllowMultipleValues. If no additional flags are required, 0 should be used. -func (c *Command) Flag(name, alias, valuePlaceholder, usage string, types, flags int64, opts ...paramOption) { +func (c *Command) Flag( + name, alias, valuePlaceholder, usage string, + types, flags int64, + opts ...ParamOption, +) { if c.flags == nil { c.flags = map[string]*param{} } + c.flags[name] = ¶m{ name: name, alias: alias, - usage: usage, + usage: usage, valuePlaceholder: valuePlaceholder, valueType: types, flags: flags, @@ -49,40 +55,50 @@ func (c *Command) Flag(name, alias, valuePlaceholder, usage string, types, flags // Arg adds an argument to a command and returns a pointer to Param instance. It is the same as adding flag except // it does not have an alias. -func (c *Command) Arg(name, valuePlaceholder, usage string, types, flags int64, opts ...paramOption) { - if c.argsIdx > 9 { +func (c *Command) Arg( + name, valuePlaceholder, usage string, + types, flags int64, + opts ...ParamOption, +) { + if c.argsIdx > maxArgs-1 { log.Fatal("Only 10 arguments are allowed") } + if c.args == nil { c.args = map[string]*param{} } + c.args[name] = ¶m{ name: name, - usage: usage, + usage: usage, valuePlaceholder: valuePlaceholder, valueType: types, flags: flags, options: paramOptions{}, } if c.argsOrder == nil { - c.argsOrder = make([]string, 10) + c.argsOrder = make([]string, maxArgs) } + c.argsOrder[c.argsIdx] = name + c.argsIdx++ - for _, o := range opts { - o(&(c.args[name].options)) + + for _, opt := range opts { + opt(&(c.args[name].options)) } } // Env adds a required environment variable to a command and returns a pointer to Param. It's arguments are very // similar to ones in previous AddArg and AddFlag methods. -func (c *Command) Env(name, usage string, types, flags int64, opts ...paramOption) { +func (c *Command) Env(name, usage string, types, flags int64, _ ...ParamOption) { if c.env == nil { c.env = map[string]*param{} } + c.env[name] = ¶m{ name: name, - usage: usage, + usage: usage, valueType: types, flags: flags, options: paramOptions{}, @@ -90,104 +106,147 @@ func (c *Command) Env(name, usage string, types, flags int64, opts ...paramOptio } func (c *Command) sortedArgs() []string { - args := make([]string, c.argsIdx) + argNamesSorted := make([]string, c.argsIdx) idx := 0 - for i := 0; i < c.argsIdx; i++ { - n := c.argsOrder[i] - arg := c.args[n] + + // required args first + for argIdx := range c.argsIdx { + argOrderedName := c.argsOrder[argIdx] + + arg := c.args[argOrderedName] + if arg.flags&IsRequired > 0 { - args[idx] = n + argNamesSorted[idx] = argOrderedName idx++ } } - for i := 0; i < c.argsIdx; i++ { - n := c.argsOrder[i] - arg := c.args[n] + + // optional args + for argIdx := range c.argsIdx { + argOrderedName := c.argsOrder[argIdx] + + arg := c.args[argOrderedName] + if arg.flags&IsRequired == 0 { - args[idx] = n + argNamesSorted[idx] = argOrderedName idx++ } } - return args + + return argNamesSorted } func (c *Command) sortedFlags() []string { - fs := reflect.ValueOf(c.flags).MapKeys() - sfs := make([]string, len(fs)) - for i, f := range fs { - sfs[i] = f.String() + flagNames := reflect.ValueOf(c.flags).MapKeys() + + flagNamesSorted := make([]string, len(flagNames)) + + for i, flagName := range flagNames { + flagNamesSorted[i] = flagName.String() } - sort.Strings(sfs) - return sfs + + sort.Strings(flagNamesSorted) + + return flagNamesSorted } func (c *Command) sortedEnv() []string { - evs := reflect.ValueOf(c.env).MapKeys() - sevs := make([]string, len(evs)) - for i, ev := range evs { - sevs[i] = ev.String() + envNames := reflect.ValueOf(c.env).MapKeys() + + envNamesSorted := make([]string, len(envNames)) + + for i, envName := range envNames { + envNamesSorted[i] = envName.String() } - sort.Strings(sevs) - return sevs + + sort.Strings(envNamesSorted) + + return envNamesSorted } // PrintHelp prints command usage information to stdout file. func (c *Command) printHelp() { - fmt.Fprintf(os.Stdout, "\nUsage: %s %s [FLAGS]%s\n\n", path.Base(os.Args[0]), c.name, + var helpMessage strings.Builder + + _, _ = fmt.Fprintf(&helpMessage, "\nUsage: %s %s [FLAGS]%s\n\n", path.Base(os.Args[0]), c.name, c.argsHelpLine()) - fmt.Fprintf(os.Stdout, "%s\n", c.usage) + _, _ = fmt.Fprintf(&helpMessage, "%s\n", c.usage) if len(c.env) > 0 { - fmt.Fprintf(os.Stdout, "\nRequired environment variables:\n") - w := new(tabwriter.Writer) - w.Init(os.Stdout, 8, 8, 0, '\t', 0) - for _, n := range c.sortedEnv() { - fmt.Fprintf(w, "%s\t%s\n", n, c.env[n].usage) + _, _ = fmt.Fprintf(&helpMessage, "\nRequired environment variables:\n") + + tabFormatter := new(tabwriter.Writer) + tabFormatter.Init( + &helpMessage, + tabWriterMinWidth, + tabWriterTabWidth, + tabWriterPadding, + tabWriterPadChar, + 0, + ) + + for _, envName := range c.sortedEnv() { + _, _ = fmt.Fprintf(tabFormatter, "%s\t%s\n", envName, c.env[envName].usage) } - w.Flush() + + _ = tabFormatter.Flush() } - w := new(tabwriter.Writer) - w.Init(os.Stdout, 8, 8, 0, '\t', 0) + tabFormatter := new(tabwriter.Writer) + tabFormatter.Init( + &helpMessage, + tabWriterMinWidth, + tabWriterTabWidth, + tabWriterPadding, + tabWriterPadChar, + 0, + ) - var s [2]string - i := 1 - for _, n := range c.sortedFlags() { - flag := c.flags[n] + var usageFlags [2]string + + for _, flagName := range c.sortedFlags() { + flag := c.flags[flagName] if flag.flags&IsRequired > 0 { - i = 0 + usageFlags[0] += flag.helpLine() } else { - i = 1 + usageFlags[1] += flag.helpLine() } - s[i] += flag.helpLine() } - if s[0] != "" { - fmt.Fprintf(w, "\nRequired flags: \n") - fmt.Fprintf(w, s[0]) - w.Flush() + if usageFlags[0] != "" { + _, _ = fmt.Fprintf(tabFormatter, "\nRequired flags: \n") + _, _ = fmt.Fprintf(tabFormatter, usageFlags[0]) + _ = tabFormatter.Flush() } - if s[1] != "" { - fmt.Fprintf(w, "\nOptional flags: \n") - fmt.Fprintf(w, s[1]) - w.Flush() + + if usageFlags[1] != "" { + _, _ = fmt.Fprintf(tabFormatter, "\nOptional flags: \n") + _, _ = fmt.Fprintf(tabFormatter, usageFlags[1]) + _ = tabFormatter.Flush() } + _, err := fmt.Fprint(os.Stdout, helpMessage.String()) + if err != nil { + fmt.Fprintf(os.Stderr, "ERROR: Unable to build help message") + } } func (c *Command) argsHelpLine() string { - sr := "" - so := "" + argsRequired := "" + argsOptional := "" + if c.argsIdx > 0 { - for i := 0; i < c.argsIdx; i++ { - n := c.argsOrder[i] - arg := c.args[n] + for argIdx := range c.argsIdx { + flagOrderedName := c.argsOrder[argIdx] + + arg := c.args[flagOrderedName] if arg.flags&IsRequired > 0 { - sr += " " + arg.valuePlaceholder + argsRequired += " " + arg.valuePlaceholder } else { - so += " [" + arg.valuePlaceholder + "]" + argsOptional += " [" + arg.valuePlaceholder + "]" } } } - return sr + so + + return argsRequired + argsOptional } diff --git a/cmd/example1/main.go b/cmd/example1/main.go index e1dafb7..db54729 100644 --- a/cmd/example1/main.go +++ b/cmd/example1/main.go @@ -1,61 +1,83 @@ +// main is an example broccli usage. package main import ( "bufio" + "context" + "crypto/rand" "fmt" + "math/big" "os" + "path/filepath" "strings" - rand "math/rand/v2" - "github.com/keenbytes/broccli/v3" ) func main() { - cli := broccli.NewCLI("example1", "Example app", "author@example.com") + cli := broccli.NewBroccli("example1", "Example app", "author@example.com") + + printCmd := cli.Command("print", "Prints a hello message", printHandler) - printCmd := cli.AddCmd("print", "Prints a hello message", printHandler) + printCmd.Arg( + "first-name", + "FIRST_NAME", + "First name of the person to welcome", + broccli.TypeString, + broccli.IsRequired, + ) + printCmd.Arg("last-name", "LAST_NAME", "Optional last name", broccli.TypeString, 0) - printCmd.AddArg("first-name", "FIRST_NAME", "First name of the person to welcome", broccli.TypeString, broccli.IsRequired) - printCmd.AddArg("last-name", "LAST_NAME", "Optional last name", broccli.TypeString, 0) - - printCmd.AddFlag("language-file", "l", "PATH_TO_FILE", "File containing 'hello' in many languages", broccli.TypePathFile, broccli.IsRegularFile|broccli.IsExistent|broccli.IsRequired) - printCmd.AddFlag("alternative", "a", "", "Use alternative welcoming", broccli.TypeBool, 0) + printCmd.Flag( + "language-file", + "l", + "PATH_TO_FILE", + "File containing 'hello' in many languages", + broccli.TypePathFile, + broccli.IsRegularFile|broccli.IsExistent|broccli.IsRequired, + ) + printCmd.Flag("alternative", "a", "", "Use alternative welcoming", broccli.TypeBool, 0) - os.Exit(cli.Run()) + os.Exit(cli.Run(context.Background())) } -func printHandler(c *broccli.CLI) int { - langFile := c.Flag("language-file") - file, err := os.Open(langFile) +func printHandler(_ context.Context, cli *broccli.Broccli) int { + langFile := cli.Flag("language-file") + + file, err := os.Open(filepath.Clean(langFile)) if err != nil { fmt.Fprintf(os.Stderr, "error opening file %s: %s", langFile, err.Error()) + return 1 } var lines []string + scanner := bufio.NewScanner(file) for scanner.Scan() { - line := scanner.Text() - if line != "" { - lines = append(lines, line) - } + line := scanner.Text() + if line != "" { + lines = append(lines, line) + } } - - i := rand.IntN(len(lines)-1) - messageArr := strings.Split(lines[i], ":") + + i, _ := rand.Int(rand.Reader, big.NewInt(int64(len(lines)-1))) + messageArr := strings.Split(lines[i.Int64()], ":") + message := messageArr[0] - if c.Flag("alternative") == "true" { + + if cli.Flag("alternative") == "true" { message = messageArr[1] } - firstName := c.Arg("first-name") + firstName := cli.Arg("first-name") + lastName := "" - if c.Arg("last-name") != "" { - lastName = fmt.Sprintf(" %s", c.Arg("last-name")) + if cli.Arg("last-name") != "" { + lastName = " " + cli.Arg("last-name") } - fmt.Fprintf(os.Stdout, "%s, %s%s!", message, firstName, lastName) + _, _ = fmt.Fprintf(os.Stdout, "%s, %s%s!", message, firstName, lastName) return 0 } diff --git a/cmd_options.go b/cmd_options.go index d5e18c4..48c8d1f 100644 --- a/cmd_options.go +++ b/cmd_options.go @@ -4,9 +4,12 @@ type commandOptions struct { onPostValidation func(c *Command) error } -type commandOption func(opts *commandOptions) +// CommandOption defines an optional configuration function for commands, intended for specific use cases. +// It should not be created manually; use one of the predefined functions below. +type CommandOption func(opts *commandOptions) -func OnPostValidation(fn func(c *Command) error) commandOption { +// OnPostValidation attaches a function that is called once args, flags and env vars are validated. +func OnPostValidation(fn func(c *Command) error) CommandOption { return func(opts *commandOptions) { opts.onPostValidation = fn } diff --git a/cmd_test.go b/cmd_test.go index de4dbc5..8bf3fb4 100644 --- a/cmd_test.go +++ b/cmd_test.go @@ -7,11 +7,13 @@ import ( // TestCommandParams creates a dummy Command instance and tests attaching flags, args and environment variables. func TestCommandParams(t *testing.T) { + t.Parallel() + c := &Command{} c.Flag("flag1", "f1", "int", "Flag 1", TypeInt, IsRequired) c.Flag("flag2", "f2", "path", "Flag 2", TypePathFile, IsRegularFile) c.Flag("flag3", "f3", "", "Flag 3", TypeBool, 0, OnTrue(func(c *Command) { - c.flags["flag2"].flags = c.flags["flag2"].flags | IsExistent + c.flags["flag2"].flags |= IsExistent })) c.Arg("arg1", "ARG1", "Arg 1", TypeInt, IsRequired) c.Arg("arg2", "ARG2", "Arg 2", TypeAlphanumeric, 0) @@ -24,16 +26,19 @@ func TestCommandParams(t *testing.T) { if len(sa) != 2 || len(sf) != 3 || len(se) != 1 { t.Errorf("Invalid args or flags or env vars added") } + for i, a := range sa { if a != fmt.Sprintf("arg%d", (i+1)) { t.Errorf("Invalid arg was added") } } + for i, f := range sf { if f != fmt.Sprintf("flag%d", (i+1)) { t.Errorf("Invalid flag was added") } } + for i, e := range se { if e != fmt.Sprintf("ENVVAR%d", (i+1)) { t.Errorf("Invalid env var was added") diff --git a/doc.go b/doc.go deleted file mode 100644 index cf77368..0000000 --- a/doc.go +++ /dev/null @@ -1,7 +0,0 @@ -/* -Package broccli is meant to make handling command line interface easier. - -You define commands with arguments, flags, attach a handler to it and package does all the parsing. -*/ - -package broccli diff --git a/flags.go b/flags.go deleted file mode 100644 index 6253243..0000000 --- a/flags.go +++ /dev/null @@ -1,41 +0,0 @@ -package broccli - -const ( - // Command param type - _ = iota * 1 - ParamFlag - ParamArg - ParamEnvVar -) - -const ( - // Value types - TypeString = iota * 1 - TypeBool - TypeInt - TypeFloat - TypeAlphanumeric - TypePathFile -) - -const ( - _ = 1 << iota - // Validation - IsRequired // IsRequired means that the value is required - IsExistent // IsExistent is used with TypePathFile and requires file to exist - IsNotExistent // IsNotExistent is used with TypePathFile and requires file not to exist - IsDirectory // IsDirectory is used with TypePathFile and requires file to be a directory - IsRegularFile // IsRegularFile is used with TypePathFile and requires file to be a regular file - IsValidJSON // IsValidJSON is used with TypeString or TypePathFile with RegularFile to check if the contents are a valid JSON - - AllowDots // AllowDots can be used only with TypeAlphanumeric and additionally allows flag to have dots. - AllowUnderscore // AllowUnderscore can be used only with TypeAlphanumeric and additionally allows flag to have underscore chars. - AllowHyphen // AllowHyphen can be used only with TypeAlphanumeric and additionally allows flag to have hyphen chars. - - // AllowMultipleValues allows param to have more than one value separated by comma by default. - // For example: AllowMany with TypeInt allows values like: 123 or 123,455,666 or 12,222 - // AllowMany works only with TypeInt, TypeFloat and TypeAlphanumeric. - AllowMultipleValues - SeparatorColon // SeparatorColon works with AllowMultipleValues and sets colon to be the value separator, instead of colon. - SeparatorSemiColon // SeparatorSemiColon works with AllowMultipleValues and sets semi-colon to be the value separator. -) diff --git a/main.go b/main.go new file mode 100644 index 0000000..475bd31 --- /dev/null +++ b/main.go @@ -0,0 +1,75 @@ +// Package broccli is meant to make handling command line interface easier. +// Define commands with arguments, flags, attach a handler to it and package will do all the parsing. +package broccli + +// Command param type. +const ( + _ = iota * 1 + // ParamFlag sets param to be a command flag. + ParamFlag + // ParamFlag sets param to be a command arg. + ParamArg + // ParamFlag sets param to be an environment variable. + ParamEnvVar +) + +// Value types. +const ( + // TypeString requires param to be a string. + TypeString = iota * 1 + // TypeBool requires param to be a boolean. + TypeBool + // TypeInt requires param to be an integer. + TypeInt + // TypeFloat requires param to be a float. + TypeFloat + // TypeAlphanumeric requires param to contain numbers and latin letters only. + TypeAlphanumeric + // TypePathFile requires param to be a path to a file. + TypePathFile +) + +// Validation. +const ( + _ = 1 << iota + // IsRequired means that the value is required. + IsRequired + // IsExistent is used with TypePathFile and requires file to exist. + IsExistent + // IsNotExistent is used with TypePathFile and requires file not to exist. + IsNotExistent + // IsDirectory is used with TypePathFile and requires file to be a directory. + IsDirectory + // IsRegularFile is used with TypePathFile and requires file to be a regular file. + IsRegularFile + // IsValidJSON is used with TypeString or TypePathFile with RegularFile to check if the contents are a valid JSON. + IsValidJSON + + // AllowDots can be used only with TypeAlphanumeric and additionally allows flag to have dots. + AllowDots + // AllowUnderscore can be used only with TypeAlphanumeric and additionally allows flag to have underscore chars. + AllowUnderscore + // AllowHyphen can be used only with TypeAlphanumeric and additionally allows flag to have hyphen chars. + AllowHyphen + + // AllowMultipleValues allows param to have more than one value separated by comma by default. + // For example: AllowMany with TypeInt allows values like: 123 or 123,455,666 or 12,222 + // AllowMany works only with TypeInt, TypeFloat and TypeAlphanumeric. + AllowMultipleValues + // SeparatorColon works with AllowMultipleValues and sets colon to be the value separator, instead of colon. + SeparatorColon + // SeparatorSemiColon works with AllowMultipleValues and sets semi-colon to be the value separator. + SeparatorSemiColon +) + +const ( + tabWriterMinWidth = 8 + tabWriterMinWidthForCommand = 10 + tabWriterTabWidth = 8 + tabWriterPadding = 8 + tabWriterPadChar = '\t' +) + +const ( + maxArgs = 10 +) diff --git a/param.go b/param.go index 9a19dfe..0cc1b65 100644 --- a/param.go +++ b/param.go @@ -5,9 +5,51 @@ import ( "errors" "fmt" "os" + "path/filepath" "regexp" ) +var ( + errFileNotExist = errors.New("file does not exist") + errFileInfo = errors.New("file cannot be opened for stat info") + errFileExist = errors.New("file already exists") + errFileNotRegularFile = errors.New("file is not a regular file") + errFileNotDirectory = errors.New("file is not a directory") + errFileOpen = errors.New("file cannot be opened") + errFileNotValidJSON = errors.New("file is not a valid JSON") + errParamValueMissing = errors.New("param value missing") + errParamValueInvalid = errors.New("param value invalid") + errParamTypeInvalid = errors.New("param type invalid") +) + +func errFileNotExistInPath(path string) error { + return fmt.Errorf("%w: %s", errFileNotExist, path) +} + +func errFileInfoInPath(path string) error { + return fmt.Errorf("%w: %s", errFileInfo, path) +} + +func errFileExistInPath(path string) error { + return fmt.Errorf("%w: %s", errFileExist, path) +} + +func errFileNotRegularFileInPath(path string) error { + return fmt.Errorf("%w: %s", errFileNotRegularFile, path) +} + +func errFileNotDirectoryInPath(path string) error { + return fmt.Errorf("%w: %s", errFileNotDirectory, path) +} + +func errFileOpenInPath(reason string, path string) error { + return fmt.Errorf("%s %w: %s", reason, errFileOpen, path) +} + +func errFileNotValidJSONInPath(path string) error { + return fmt.Errorf("%w: %s", errFileNotValidJSON, path) +} + // param represends a value and it is used for flags, args and environment variables. // It has a name, alias, usage, value that is shown when printing help, specific type (eg. TypeBool or TypeInt), // If more than one value shoud be allowed, eg. '1,2,3' means "multiple integers" and the separator here is ','. @@ -17,7 +59,7 @@ type param struct { name string alias string valuePlaceholder string - usage string + usage string valueType int64 flags int64 options paramOptions @@ -25,21 +67,63 @@ type param struct { // helpLine returns param usage info that is used when printing help. func (p *param) helpLine() string { - s := " " + usageLine := " " if p.alias == "" { - s += " \t" + usageLine += " \t" } else { - s += fmt.Sprintf(" -%s,\t", p.alias) + usageLine += fmt.Sprintf(" -%s,\t", p.alias) } - s += fmt.Sprintf(" --%s %s \t%s\n", p.name, p.valuePlaceholder, p.usage) - return s + + usageLine += fmt.Sprintf(" --%s %s \t%s\n", p.name, p.valuePlaceholder, p.usage) + + return usageLine } -// ValidateValue takes value coming from --NAME and -ALIAS and validates it. -func (p *param) validateValue(v string) error { +func (p *param) validatePathFile(path string) error { + fileInfo, err := os.Stat(path) + if err != nil { + if os.IsNotExist(err) { + if p.flags&IsExistent > 0 { + return errFileNotExistInPath(path) + } + + return nil + } + + return errFileInfoInPath(path) + } + + if p.flags&IsNotExistent > 0 { + return errFileExistInPath(path) + } + + if !fileInfo.Mode().IsRegular() && (p.flags&IsRegularFile > 0) { + return errFileNotRegularFileInPath(path) + } + + if !fileInfo.Mode().IsDir() && (p.flags&IsDirectory > 0) { + return errFileNotDirectoryInPath(path) + } + + if (p.flags&IsRegularFile > 0) && (p.flags&IsValidJSON > 0) { + dat, err := os.ReadFile(filepath.Clean(path)) + if err != nil { + return errFileOpenInPath("validate json", path) + } + + if !json.Valid(dat) { + return errFileNotValidJSONInPath(path) + } + } + + return nil +} + +//nolint:funlen +func (p *param) validateValue(paramValue string) error { // empty, for every time except bool - if p.valueType != TypeBool && (p.flags&IsRequired > 0) && v == "" { - return errors.New("missing value") + if p.valueType != TypeBool && (p.flags&IsRequired > 0) && paramValue == "" { + return errParamValueMissing } // string does not need any additional checks apart from the above one @@ -48,54 +132,27 @@ func (p *param) validateValue(v string) error { } // if param is not required or not empty - if !(p.flags&IsRequired > 0 || v != "") { + if p.flags&IsRequired <= 0 && paramValue == "" { return nil } // if flag is a file (regular file, directory, ...) if p.valueType == TypePathFile { - fileInfo, err := os.Stat(v) - if err != nil { - if os.IsNotExist(err) { - if p.flags&IsExistent > 0 { - return fmt.Errorf("file %s does not exist", v) - } else { - return nil - } - } else { - return fmt.Errorf("file %s cannot be opened for info", v) - } - } - - if p.flags&IsNotExistent > 0 { - return fmt.Errorf("file %s already exists", v) - } - - if !fileInfo.Mode().IsRegular() && (p.flags&IsRegularFile > 0) { - return fmt.Errorf("path %s is not a regular file", v) - } - - if !fileInfo.Mode().IsDir() && (p.flags&IsDirectory > 0) { - return fmt.Errorf("path %s is not a directory", v) - } - - if (p.flags&IsRegularFile > 0) && (p.flags&IsValidJSON > 0) { - dat, err := os.ReadFile(v) - if err != nil { - return fmt.Errorf("file %s cannot be opened for JSON validation: %w", v, err) - } - if !json.Valid(dat) { - return fmt.Errorf("file %s is not a valid JSON", v) - } + errValidatePathFile := p.validatePathFile(paramValue) + if errValidatePathFile != nil { + return fmt.Errorf("file path validation failed: %w", errValidatePathFile) } return nil } // int, float, alphanumeric - single or many, separated by various chars - var reType string - var reValue string + var ( + reType string + reValue string + ) // set regexp part just for the type (eg. int, float, anum) + switch p.valueType { case TypeInt: reType = "[0-9]+" @@ -106,34 +163,40 @@ func (p *param) validateValue(v string) error { if p.flags&AllowUnderscore > 0 { reExtraChars += "_" } + if p.flags&AllowDots > 0 { reExtraChars += "\\." } + if p.flags&AllowHyphen > 0 { reExtraChars += "\\-" } + reType = fmt.Sprintf("[0-9a-zA-Z%s]+", reExtraChars) default: - return errors.New("invalid type") + return errParamTypeInvalid } // create the final regexp depending on if single or many values are allowed if p.flags&AllowMultipleValues > 0 { - var d string + var delimeter string + //nolint:gocritic if p.flags&SeparatorColon > 0 { - d = ":" + delimeter = ":" } else if p.flags&SeparatorSemiColon > 0 { - d = ";" + delimeter = ";" } else { - d = "," + delimeter = "," } - reValue = "^" + reType + "(" + d + reType + ")*$" + + reValue = "^" + reType + "(" + delimeter + reType + ")*$" } else { reValue = "^" + reType + "$" } - m, err := regexp.MatchString(reValue, v) + + m, err := regexp.MatchString(reValue, paramValue) if err != nil || !m { - return errors.New("invalid value") + return errParamValueInvalid } return nil diff --git a/param_options.go b/param_options.go index 359fc20..f539edf 100644 --- a/param_options.go +++ b/param_options.go @@ -1,12 +1,15 @@ package broccli type paramOptions struct { - onTrue func(c *Command) + onTrue func(command *Command) } -type paramOption func(opts *paramOptions) +// ParamOption defines an optional configuration function for args and flags, intended for specific use cases. +// It should not be created manually; use one of the predefined functions below. +type ParamOption func(opts *paramOptions) -func OnTrue(fn func(c *Command)) paramOption { +// OnTrue executes a specified function when boolean flag is true. +func OnTrue(fn func(command *Command)) ParamOption { return func(opts *paramOptions) { opts.onTrue = fn } diff --git a/param_test.go b/param_test.go index 7a94e51..647b0ec 100644 --- a/param_test.go +++ b/param_test.go @@ -1,17 +1,14 @@ package broccli import ( - "log" "os" "testing" ) -func h(c *Broccli) int { - return 0 -} - // TestParamValidationBasic tests basic validation for specific types. func TestParamValidationBasic(t *testing.T) { + t.Parallel() + p := ¶m{} if p.validateValue("") != nil { t.Errorf("Empty param should validate") @@ -28,6 +25,7 @@ func TestParamValidationBasic(t *testing.T) { if p.validateValue("48") != nil { t.Errorf("Int param should validate") } + if p.validateValue("aa") == nil { t.Errorf("Int param should not validate string") } @@ -36,9 +34,11 @@ func TestParamValidationBasic(t *testing.T) { if p.validateValue("48.998") != nil { t.Errorf("Float param should validate") } + if p.validateValue("48") == nil { t.Errorf("Float param should not validate int") } + if p.validateValue("aa") == nil { t.Errorf("Float param should not validate string") } @@ -47,6 +47,7 @@ func TestParamValidationBasic(t *testing.T) { if p.validateValue("a123aaAEz") != nil { t.Errorf("Alphanumeric param should validate") } + if p.validateValue("a.z") == nil { t.Errorf("Alphanumeric param should not validate") } @@ -59,12 +60,15 @@ func TestParamValidationBasic(t *testing.T) { // TestParamValidationRequired tests IsRequired flag. func TestParamValidationRequired(t *testing.T) { + t.Parallel() + p := ¶m{ flags: IsRequired, } if p.validateValue("") == nil { t.Errorf("Empty param with IsRequired should not validate") } + if p.validateValue("aa") != nil { t.Errorf("Param with IsRequired should validate") } @@ -73,6 +77,8 @@ func TestParamValidationRequired(t *testing.T) { // TestParamValidationExtraChars tests flags such as AllowUnderscore that allow TypeAlphanumeric to contain // extra characters. func TestParamValidationExtraChars(t *testing.T) { + t.Parallel() + p := ¶m{ valueType: TypeAlphanumeric, flags: AllowDots, @@ -90,6 +96,7 @@ func TestParamValidationExtraChars(t *testing.T) { if p.validateValue("aZ09-09") != nil { t.Errorf("Alphanumeric param with extra chars should validate") } + if p.validateValue("aZ09-_09") == nil { t.Errorf("Alphanumeric param with extra chars should fail") } @@ -102,6 +109,8 @@ func TestParamValidationExtraChars(t *testing.T) { // TestParamValidationMultipleValues tests params that allow multiple values. func TestParamValidationMultipleValues(t *testing.T) { + t.Parallel() + p := ¶m{ valueType: TypeAlphanumeric, flags: AllowMultipleValues | AllowDots, @@ -132,11 +141,19 @@ func TestParamValidationMultipleValues(t *testing.T) { // TestParamValidationFiles creates param of TypePathFile and tests additional validation flags related to checking // if file is a regular file, if it exists etc. func TestParamValidationFiles(t *testing.T) { - f, err := os.CreateTemp("", "example") + t.Parallel() + + f, err := os.CreateTemp(t.TempDir(), "example") if err != nil { - log.Fatal(err) + t.Errorf("error creating temporary file") } - defer os.Remove(f.Name()) + + defer func() { + err := os.Remove(f.Name()) + if err != nil { + t.Errorf("error removing temporary file") + } + }() p := ¶m{ valueType: TypePathFile, @@ -149,9 +166,11 @@ func TestParamValidationFiles(t *testing.T) { if p.validateValue("/non-existing/path") != nil { t.Errorf("PathFile param with IsNotExistent should validate") } + if p.validateValue(f.Name()) == nil { t.Errorf("PathFile param with IsNotExistent should fail") } + if p.validateValue("") != nil { t.Errorf("Empty PathFile param with IsNotExistent should validate") } @@ -160,9 +179,11 @@ func TestParamValidationFiles(t *testing.T) { if p.validateValue("/non-existing/path") == nil { t.Errorf("PathFile param with IsExistent should fail") } + if p.validateValue(f.Name()) != nil { t.Errorf("PathFile param with IsNotExistent should validate") } + if p.validateValue("") != nil { t.Errorf("Empty PathFile param with IsExistent should validate") } @@ -171,6 +192,7 @@ func TestParamValidationFiles(t *testing.T) { if p.validateValue("") != nil { t.Errorf("Empty PathFile param with IsRegularFile should validate") } + if p.validateValue(f.Name()) != nil { t.Errorf("PathFile param with IsRegularFile should validate") } @@ -179,6 +201,7 @@ func TestParamValidationFiles(t *testing.T) { if p.validateValue("") != nil { t.Errorf("Empty PathFile param with IsDirectory should validate") } + if p.validateValue("") != nil { t.Errorf("Empty PathFile param with IsDirectory should validate") } @@ -193,20 +216,26 @@ func TestParamValidationFiles(t *testing.T) { t.Errorf("PathFile param with IsExistent should fail") } - if _, err := f.Write([]byte("{\"valid\":\"json\"}")); err != nil { - log.Fatal(err) + _, err = f.WriteString("{\"valid\":\"json\"}") + if err != nil { + t.Errorf("error writing to file") } + p.flags = IsExistent | IsRegularFile | IsValidJSON if p.validateValue(f.Name()) != nil { t.Errorf("PathFile param with IsExistent should validate") } - if _, err := f.Write([]byte("in{\"valid\":\"json\"}")); err != nil { - log.Fatal(err) + _, err = f.WriteString("{\"valid\":\"json\"}") + if err != nil { + t.Errorf("error writing to file") } - if err := f.Close(); err != nil { - log.Fatal(err) + + err = f.Close() + if err != nil { + t.Errorf("error closing file") } + p.flags = IsExistent | IsRegularFile | IsValidJSON if p.validateValue(f.Name()) == nil { t.Errorf("PathFile param with IsExistent should fail") diff --git a/version.go b/version.go index e137251..4c7ac11 100644 --- a/version.go +++ b/version.go @@ -1,3 +1,4 @@ package broccli -const VERSION = "3.0.2" +// VERSION is the current version of the module. +const VERSION = "3.1.0"