package cli import ( "context" "fmt" "io" "strings" "testing" "github.com/stretchr/testify/require" ) var errBothEnvFlagsAreSet = fmt.Errorf("cannot set both flags `--env` and `--no-env`") type boolWithInverseTestCase struct { args []string toBeSet bool value bool err error envVars map[string]string } func (tc *boolWithInverseTestCase) Run(t *testing.T, flagWithInverse *BoolWithInverseFlag) error { cmd := &Command{ Flags: []Flag{flagWithInverse}, Action: func(context.Context, *Command) error { return nil }, } for key, val := range tc.envVars { t.Setenv(key, val) } err := cmd.Run(buildTestContext(t), append([]string{"prog"}, tc.args...)) if err != nil { return err } if flagWithInverse.IsSet() != tc.toBeSet { return fmt.Errorf("flag should be set %t, but got %t", tc.toBeSet, flagWithInverse.IsSet()) } if flagWithInverse.Get() != tc.value { return fmt.Errorf("flag value should be %t, but got %t", tc.value, flagWithInverse.Get()) } return nil } func runBoolWithInverseFlagTests(t *testing.T, newFlagMethod func() *BoolWithInverseFlag, cases []*boolWithInverseTestCase) error { for _, tc := range cases { t.Run(strings.Join(tc.args, " ")+fmt.Sprintf("%[1]v %[2]v %[3]v", tc.value, tc.toBeSet, tc.err), func(t *testing.T) { r := require.New(t) fl := newFlagMethod() err := tc.Run(t, fl) if err != nil && tc.err == nil { r.NoError(err) } if err == nil && tc.err != nil { r.Error(err) } if err != nil && tc.err != nil { r.ErrorContains(err, tc.err.Error()) } }) } return nil } func TestBoolWithInverseBasic(t *testing.T) { flagMethod := func() *BoolWithInverseFlag { return &BoolWithInverseFlag{ Name: "env", } } testCases := []*boolWithInverseTestCase{ { args: []string{"--no-env"}, toBeSet: true, value: false, }, { args: []string{"--env"}, toBeSet: true, value: true, }, { toBeSet: false, value: false, }, { args: []string{"--env", "--no-env"}, err: errBothEnvFlagsAreSet, }, } err := runBoolWithInverseFlagTests(t, flagMethod, testCases) if err != nil { t.Error(err) return } } func TestBoolWithInverseAction(t *testing.T) { err := fmt.Errorf("action called") flagMethod := func() *BoolWithInverseFlag { bif := &BoolWithInverseFlag{ Name: "env", // Setting env to the opposite to test flag Action is working as intended Action: func(_ context.Context, cmd *Command, value bool) error { return err }, } return bif } testCases := []*boolWithInverseTestCase{ { args: []string{"--no-env"}, toBeSet: true, value: false, err: err, }, { args: []string{"--env"}, toBeSet: true, value: true, err: err, }, // This test is not inverse because the flag action is never called { toBeSet: false, value: false, }, { args: []string{"--env", "--no-env"}, err: errBothEnvFlagsAreSet, }, } errr := runBoolWithInverseFlagTests(t, flagMethod, testCases) if errr != nil { t.Error(errr) return } } func TestBoolWithInverseAlias(t *testing.T) { flagMethod := func() *BoolWithInverseFlag { return &BoolWithInverseFlag{ Name: "env", Aliases: []string{"e", "do-env"}, } } testCases := []*boolWithInverseTestCase{ { args: []string{"--no-e"}, toBeSet: true, value: false, }, { args: []string{"--e"}, toBeSet: true, value: true, }, { toBeSet: false, value: false, }, { args: []string{"--do-env", "--no-do-env"}, err: errBothEnvFlagsAreSet, }, } err := runBoolWithInverseFlagTests(t, flagMethod, testCases) if err != nil { t.Error(err) return } } func TestBoolWithInverseEnvVars(t *testing.T) { flagMethod := func() *BoolWithInverseFlag { return &BoolWithInverseFlag{ Name: "env", Sources: EnvVars("ENV", "NO-ENV"), Local: true, } } testCases := []*boolWithInverseTestCase{ { toBeSet: true, value: false, envVars: map[string]string{ "NO-ENV": "false", }, }, { toBeSet: true, value: true, envVars: map[string]string{ "ENV": "true", }, }, { toBeSet: true, value: false, envVars: map[string]string{ "ENV": "false", }, }, { toBeSet: false, value: false, }, // TODO /*{ err: errBothEnvFlagsAreSet, envVars: map[string]string{ "ENV": "true", "NO-ENV": "true", }, },*/ { err: fmt.Errorf("could not parse \"true_env\" as bool value from environment variable \"ENV\" for flag env: parse error"), envVars: map[string]string{ "ENV": "true_env", }, }, { err: fmt.Errorf("could not parse \"false_env\" as bool value from environment variable \"NO-ENV\" for flag env: parse error"), envVars: map[string]string{ "NO-ENV": "false_env", }, }, } err := runBoolWithInverseFlagTests(t, flagMethod, testCases) if err != nil { t.Error(err) return } } func TestBoolWithInverseWithPrefix(t *testing.T) { flagMethod := func() *BoolWithInverseFlag { return &BoolWithInverseFlag{ Name: "env", InversePrefix: "without-", } } testCases := []*boolWithInverseTestCase{ { args: []string{"--without-env"}, toBeSet: true, value: false, }, { args: []string{"--env"}, toBeSet: true, value: true, }, { toBeSet: false, value: false, }, { args: []string{"--env", "--without-env"}, err: fmt.Errorf("cannot set both flags `--env` and `--without-env`"), }, { args: []string{"--without-env", "--env"}, err: fmt.Errorf("cannot set both flags `--env` and `--without-env`"), }, } err := runBoolWithInverseFlagTests(t, flagMethod, testCases) if err != nil { t.Error(err) return } } func TestBoolWithInverseRequired(t *testing.T) { flagMethod := func() *BoolWithInverseFlag { return &BoolWithInverseFlag{ Name: "env", Required: true, } } testCases := []*boolWithInverseTestCase{ { args: []string{"--no-env"}, toBeSet: true, value: false, }, { args: []string{"--env"}, toBeSet: true, value: true, }, { args: []string{"--env", "--no-env"}, err: errBothEnvFlagsAreSet, }, } err := runBoolWithInverseFlagTests(t, flagMethod, testCases) if err != nil { t.Error(err) return } } func TestBoolWithInverseNames(t *testing.T) { flag := &BoolWithInverseFlag{ Name: "env", Required: true, } names := flag.Names() require.Len(t, names, 2) require.Equal(t, "env", names[0], "expected first name to be `env`") require.Equal(t, "no-env", names[1], "expected first name to be `no-env`") var d DocGenerationFlag = flag require.Equal(t, "bool", d.TypeName()) } func TestBoolWithInverseDefaultText(t *testing.T) { require.Equal(t, "false", (&BoolWithInverseFlag{}).GetDefaultText()) require.Equal(t, "true", (&BoolWithInverseFlag{Value: true}).GetDefaultText()) require.Equal(t, "auto", (&BoolWithInverseFlag{DefaultText: "auto"}).GetDefaultText()) } func TestBoolWithInverseString(t *testing.T) { tcs := []struct { testName string flagName string required bool usage string inversePrefix string aliases []string expected string }{ { testName: "empty inverse prefix no flag", flagName: "", required: true, expected: "--[no-]\t", }, { testName: "single-char flag name", flagName: "e", required: true, expected: "-[no-]e\t", }, { testName: "single-char multibyte flag name", flagName: "ü", required: true, expected: "-[no-]ü\t", }, { testName: "multi-char flag name", flagName: "env", required: true, expected: "--[no-]env\t", }, { testName: "required with usage", flagName: "env", required: true, usage: "env usage", expected: "--[no-]env\tenv usage", }, { testName: "required without usage", flagName: "env", required: true, expected: "--[no-]env\t", }, { testName: "not required with default usage", flagName: "env", required: false, expected: "--[no-]env\t(default: false)", }, { testName: "custom inverse prefix", flagName: "env", required: true, inversePrefix: "nope-", expected: "--[nope-]env\t", }, { testName: "empty inverse prefix", flagName: "env", required: true, expected: "--[no-]env\t", }, { testName: "short alias", flagName: "color", required: false, aliases: []string{"c"}, expected: "--[no-]color, -c\t(default: false)", }, { testName: "short multibyte alias", flagName: "color", required: false, aliases: []string{"ü"}, expected: "--[no-]color, -ü\t(default: false)", }, { testName: "long alias", flagName: "color", required: false, aliases: []string{"colour"}, expected: "--[no-]color, --colour\t(default: false)", }, { testName: "multiple aliases", flagName: "color", required: false, aliases: []string{"c", "colour"}, expected: "--[no-]color, -c, --colour\t(default: false)", }, } for _, tc := range tcs { t.Run(tc.testName, func(t *testing.T) { flag := &BoolWithInverseFlag{ Name: tc.flagName, Usage: tc.usage, Required: tc.required, InversePrefix: tc.inversePrefix, Aliases: tc.aliases, } require.Equal(t, tc.expected, flag.String()) }) } } func TestBoolWithInverseDestination(t *testing.T) { destination := new(bool) count := new(int) flagMethod := func() *BoolWithInverseFlag { return &BoolWithInverseFlag{ Name: "env", Destination: destination, Config: BoolConfig{ Count: count, }, } } checkAndReset := func(expectedCount int, expectedValue bool) error { if *count != expectedCount { return fmt.Errorf("expected count to be %d, got %d", expectedCount, *count) } if *destination != expectedValue { return fmt.Errorf("expected destination to be %t, got %t", expectedValue, *destination) } *count = 0 *destination = false return nil } err := (&boolWithInverseTestCase{ args: []string{"--env"}, toBeSet: true, value: true, }).Run(t, flagMethod()) if err != nil { t.Error(err) return } err = checkAndReset(1, true) if err != nil { t.Error(err) return } err = (&boolWithInverseTestCase{ args: []string{"--no-env"}, toBeSet: true, value: false, }).Run(t, flagMethod()) if err != nil { t.Error(err) return } err = checkAndReset(1, false) if err != nil { t.Error(err) return } err = (&boolWithInverseTestCase{ args: []string{}, toBeSet: false, value: false, }).Run(t, flagMethod()) if err != nil { t.Error(err) return } err = checkAndReset(0, false) if err != nil { t.Error(err) return } f := flagMethod() f.Value = true err = (&boolWithInverseTestCase{ args: []string{}, toBeSet: false, value: true, }).Run(t, f) if err != nil { t.Error(err) return } err = checkAndReset(0, true) if err != nil { t.Error(err) return } } func TestBoolWithInverseFlag_SatisfiesRequiredFlagInterface(t *testing.T) { var f RequiredFlag = &BoolWithInverseFlag{} _ = f.IsRequired() } func TestBoolWithInverseFlag_SatisfiesVisibleFlagInterface(t *testing.T) { var f VisibleFlag = &BoolWithInverseFlag{} _ = f.IsVisible() } // TestBoolWithInverseFlagStringNoPanicWithNoTabStringer is a regression test for // https://github.com/urfave/cli/issues/2303. // BoolWithInverseFlag.String() panicked with "slice bounds out of range [-1:]" // when the FlagStringer returned a string without a tab character. func TestBoolWithInverseFlagStringNoPanicWithNoTabStringer(t *testing.T) { orig := FlagStringer defer func() { FlagStringer = orig }() FlagStringer = func(f Flag) string { return "no tab here" } flag := &BoolWithInverseFlag{ Name: "verbose", } // Must not panic. got := flag.String() if !strings.Contains(got, "verbose") { t.Errorf("expected String() to contain the flag name, got %q", got) } } func TestBoolWithInverseFlagCount(t *testing.T) { for _, externalCount := range []bool{false, true} { for _, onlyOnce := range []bool{false, true} { for _, name := range []string{"--env", "--no-env", "-e", "--no-e"} { t.Run(fmt.Sprintf("external=%v/onlyOnce=%v/%s", externalCount, onlyOnce, name), func(t *testing.T) { const initialCount = 7 count := initialCount fl := &BoolWithInverseFlag{Name: "env", Aliases: []string{"e"}, OnlyOnce: onlyOnce} if externalCount { fl.Config.Count = &count } cmd := &Command{Flags: []Flag{fl}, Writer: io.Discard, ErrWriter: io.Discard} err := cmd.Run(buildTestContext(t), []string{"prog", name, name}) wantCount := 2 if onlyOnce { require.ErrorContains(t, err, "can't duplicate this flag") wantCount = 1 } else { require.NoError(t, err) } require.Equal(t, wantCount, fl.Count()) if externalCount { require.Equal(t, initialCount+wantCount, count) } }) } } } }