pflag icon indicating copy to clipboard operation
pflag copied to clipboard

feature request: footgun-proofing `--some-boolean-flag false`

Open zackelan opened this issue 5 months ago • 1 comments

minimal repro code:

package main

import (
    "fmt"

    "github.com/spf13/cobra"
)

var (
    name    string
    count   int
    enabled bool
)

var cmd = &cobra.Command{
    Use: "test",
    RunE: func(cmd *cobra.Command, args []string) error {
        fmt.Printf("name: %v\n", name)
        fmt.Printf("count: %v\n", count)
        fmt.Printf("enabled: %v\n", enabled)
        fmt.Printf("args: %v\n", args)

        return nil
    },
}

func init() {
    flags := cmd.Flags()
    flags.StringVar(&name, "name", "", "name of the thing")
    flags.IntVar(&count, "count", 0, "count of the thing")
    flags.BoolVar(&enabled, "enabled", true, "whether to enable the thing")

    cmd.MarkFlagRequired("name")
    cmd.MarkFlagRequired("count")
    cmd.MarkFlagRequired("enabled")
}

func main() {
    cmd.Execute()
}

running it:

$ ./test --name=foo --count=10 --enabled=false
name: foo
count: 10
enabled: false
args: []
$ ./test --name foo --count 10 --enabled false
name: foo
count: 10
enabled: true
args: [false]

I understand the reason it behaves this way...but I also think it's counter-intuitive that (AFAIK) every other flag type doesn't care about --flag=value or --flag value, and booleans are the one exception to this.

and I'm definitely not the only person who's been tripped up by this:

https://github.com/spf13/pflag/issues/288

https://github.com/spf13/cobra/issues/613

https://github.com/spf13/cobra/issues/1230

https://github.com/spf13/cobra/issues/1606

https://github.com/spf13/cobra/issues/1657

for the purposes of this issue, I'm asking if there's a way to make this less of a footgun. in other words, if --enabled false is the incorrect thing for the user to enter, how can we present them with an error telling them it's wrong? rather than silently plowing ahead and possibly doing the opposite of what they asked for? or, can we make --enabled false work as the user is expecting it to?

the motivation for this is that my company had a Cobra command for "add new feature flag to backend system" with --name and --default-value flags. I'm sure there are lots of use cases where having a flag value be true when the user meant for it to be false isn't a huge deal (--verbose etc). but in our case, having a feature flag that silently used true instead of false as its default value could be Real Bad.

a few possible workarounds i can think of:

A) add cobra.ExactArgs(0) to the command

@@ -14,6 +14,7 @@

 var cmd = &cobra.Command{
        Use: "test",
+       Args: cobra.ExactArgs(0),
        RunE: func(cmd *cobra.Command, args []string) error {
                fmt.Printf("name: %v\n", name)
                fmt.Printf("count: %v\n", count)

this results in an error if --enabled false is passed, but with a message that doesn't make it obvious what the user should do to fix the problem:

$ ./test --name foo --count 10 --enabled false
Error: accepts 0 arg(s), received 1
Usage:
  test [flags]

Flags:
      --count int     count of the thing
      --enabled       whether to enable the thing (default true)
  -h, --help          help for test
      --name string   name of the thing

B) explicitly check the length of the args

@@ -15,6 +15,10 @@
 var cmd = &cobra.Command{
        Use: "test",
        RunE: func(cmd *cobra.Command, args []string) error {
+               if len(args) > 0 {
+                       return fmt.Errorf("not expecting any args - make sure you're using --enabled=value and not --enabled value")
+               }
+
                fmt.Printf("name: %v\n", name)
                fmt.Printf("count: %v\n", count)
                fmt.Printf("enabled: %v\n", enabled)

this is similar to A, but at least allows printing a custom error message that can hint at the problem.

however, both A and B don't really work if the command is expecting a non-zero number of arguments under normal circumstances.

C) make the argument a bool slice

@@ -9,7 +9,7 @@
 var (
        name    string
        count   int
-       enabled bool
+       enabled []bool
 )

 var cmd = &cobra.Command{
@@ -28,7 +28,7 @@
        flags := cmd.Flags()
        flags.StringVar(&name, "name", "", "name of the thing")
        flags.IntVar(&count, "count", 0, "count of the thing")
-       flags.BoolVar(&enabled, "enabled", true, "whether to enable the thing")
+       flags.BoolSliceVar(&enabled, "enabled", []bool{true}, "whether to enable the thing")

        cmd.MarkFlagRequired("name")
        cmd.MarkFlagRequired("count")

this works, mostly:

$ ./test --name foo --count 10 --enabled false
name: foo
count: 10
enabled: [false]
args: []

but it causes the usage text to inaccurately suggest that multiple values are supported: (--enabled bools whether to enable the thing (default [false]))

I would also have to add in my own error-checking that the slice contains exactly one element.

D) make the argument a string, and then convert it myself

@@ -2,6 +2,7 @@

 import (
        "fmt"
+       "strconv"

        "github.com/spf13/cobra"
 )
@@ -9,15 +10,20 @@
 var (
        name    string
        count   int
-       enabled bool
+       enabled string
 )

 var cmd = &cobra.Command{
        Use: "test",
        RunE: func(cmd *cobra.Command, args []string) error {
+               enabledBool, err := strconv.ParseBool(enabled)
+               if err != nil {
+                       return err
+               }
+
                fmt.Printf("name: %v\n", name)
                fmt.Printf("count: %v\n", count)
-               fmt.Printf("enabled: %v\n", enabled)
+               fmt.Printf("enabled: %v\n", enabledBool)
                fmt.Printf("args: %v\n", args)

                return nil
@@ -28,7 +34,7 @@
        flags := cmd.Flags()
        flags.StringVar(&name, "name", "", "name of the thing")
        flags.IntVar(&count, "count", 0, "count of the thing")
-       flags.BoolVar(&enabled, "enabled", true, "whether to enable the thing")
+       flags.StringVar(&enabled, "enabled", "true", "whether to enable the thing")

        cmd.MarkFlagRequired("name")
        cmd.MarkFlagRequired("count")

this also works:

$ ./test --name foo --count 10 --enabled false
name: foo
count: 10
enabled: false
args: []

with the downside that you lose the BoolVar functionality of being able to specify --enabled on its own without a value (but at least with a helpful error message):

$ ./test --name foo --count 10 --enabled
Error: flag needs an argument: --enabled
Usage:
  test [flags]

Flags:
      --count int        count of the thing
      --enabled string   whether to enable the thing (default "true")
  -h, --help             help for test
      --name string      name of the thing

of these, D seems like the least-bad option...but it means that the workaround in these cases is just "don't use BoolVar, and instead reimplement its functionality in your command"

rather than any of these workarounds, I think a possible improvement to this library might be adding something like an ExplicitBoolVar function? that would behave like BoolVar, except it would consume a value the way that StringVar etc do.

having BoolVar and ExplicitBoolVar show up in the list of available functions might also help discoverability of this problem, because people will go looking to see how they're different, which could lead to a section in the documentation explaining this possible footgun.

zackelan avatar Aug 23 '24 20:08 zackelan