Repository: alecthomas/kong
Pull request #442 (2024-08-06 08:47:08)

Feature: Add xand tag
I have added the possibility to add a xand tag.

It works similarly to the xor tag, but requires all flags within the xand group to be set.

I have tried to stay consistent to the style already used, and touch existing functions as little as possible.

The one issue I have not handled here which should probably be resolved before merging is the possibility of a user putting two or more flags in the same xor and xand group, which would cause problems. The reason I have not solved this issue is because I was a bit unsure about how you would like to handle it. Should it be up to the user to check for this when testing their application? Or should kong give an error? Do you have a standard for how you handle issues like this?

alecthomas: I like the idea, but "xand" isn't really a valid boolean operator! Can we rename it to "and"?

To answer your question, I think having a check that xor and and aren't used together would be good.
alecthomas: Thanks for the PR BTW, appreciate it!
tinytyranid: > I like the idea, but "xand" isn't really a valid boolean operator! Can we rename it to "and"?
> 
> To answer your question, I think having a check that xor and and aren't used together would be good.

Renaming it to "and" is not a problem.
I will fix that right away!

I will also try to make a check for when they are used together, but that might take a bit longer to finish. 



Patch:
diff --git a/README.md b/README.md
index 916ca6f..cbe6224 100644
--- a/README.md
+++ b/README.md
@@ -554,7 +554,7 @@ Tags can be in two forms:
 Both can coexist with standard Tag parsing.
 
 | Tag                  | Description                                                                                                                                                                                                                                                                                                                    |
-| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+|----------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
 | `cmd:""`             | If present, struct is a command.                                                                                                                                                                                                                                                                                               |
 | `arg:""`             | If present, field is an argument. Required by default.                                                                                                                                                                                                                                                                         |
 | `env:"X,Y,..."`      | Specify envars to use for default value. The envs are resolved in the declared order. The first value found is used.                                                                                                                                                                                                           |
@@ -566,7 +566,7 @@ Both can coexist with standard Tag parsing.
 | `default:"1"`        | On a command, make it the default.                                                                                                                                                                                                                                                                                             |
 | `default:"withargs"` | On a command, make it the default and allow args/flags from that command                                                                                                                                                                                                                                                       |
 | `short:"X"`          | Short name, if flag.                                                                                                                                                                                                                                                                                                           |
-| `aliases:"X,Y"`      | One or more aliases (for cmd or flag).                                                                                                                                                                                                                                                                                                 |
+| `aliases:"X,Y"`      | One or more aliases (for cmd or flag).                                                                                                                                                                                                                                                                                         |
 | `required:""`        | If present, flag/arg is required.                                                                                                                                                                                                                                                                                              |
 | `optional:""`        | If present, flag/arg is optional.                                                                                                                                                                                                                                                                                              |
 | `hidden:""`          | If present, command or flag is hidden.                                                                                                                                                                                                                                                                                         |
@@ -577,6 +577,7 @@ Both can coexist with standard Tag parsing.
 | `enum:"X,Y,..."`     | Set of valid values allowed for this flag. An enum field must be `required` or have a valid `default`.                                                                                                                                                                                                                         |
 | `group:"X"`          | Logical group for a flag or command.                                                                                                                                                                                                                                                                                           |
 | `xor:"X,Y,..."`      | Exclusive OR groups for flags. Only one flag in the group can be used which is restricted within the same command. When combined with `required`, at least one of the `xor` group will be required.                                                                                                                            |
+| `and:"X,Y,..."`      | Exclsuive AND groups for flags. All flags in the group must be used in the same command. When combined with `required`, all flags in the group will be required.                                                                                                                                                               |
 | `prefix:"X"`         | Prefix for all sub-flags.                                                                                                                                                                                                                                                                                                      |
 | `envprefix:"X"`      | Envar prefix for all sub-flags.                                                                                                                                                                                                                                                                                                |
 | `set:"K=V"`          | Set a variable for expansion by child elements. Multiples can occur.                                                                                                                                                                                                                                                           |
diff --git a/build.go b/build.go
index aed1f72..18048e0 100644
--- a/build.go
+++ b/build.go
@@ -323,6 +323,7 @@ func buildField(k *Kong, node *Node, v reflect.Value, ft reflect.StructField, fv
 			Envs:        tag.Envs,
 			Group:       buildGroupForKey(k, tag.Group),
 			Xor:         tag.Xor,
+			And:         tag.And,
 			Hidden:      tag.Hidden,
 		}
 		value.Flag = flag
diff --git a/context.go b/context.go
index 03044ec..f5239c7 100644
--- a/context.go
+++ b/context.go
@@ -259,7 +259,7 @@ func (c *Context) Validate() error { //nolint: gocyclo
 	if err := checkMissingPositionals(positionals, node.Positional); err != nil {
 		return err
 	}
-	if err := checkXorDuplicates(c.Path); err != nil {
+	if err := checkXorDuplicatedAndAndMissing(c.Path); err != nil {
 		return err
 	}
 
@@ -831,23 +831,35 @@ func (c *Context) PrintUsage(summary bool) error {
 func checkMissingFlags(flags []*Flag) error {
 	xorGroupSet := map[string]bool{}
 	xorGroup := map[string][]string{}
+	andGroupSet := map[string]bool{}
+	andGroup := map[string][]string{}
 	missing := []string{}
+	andGroupRequired := getRequiredAndGroupMap(flags)
 	for _, flag := range flags {
+		for _, and := range flag.And {
+			flag.Required = andGroupRequired[and]
+		}
 		if flag.Set {
 			for _, xor := range flag.Xor {
 				xorGroupSet[xor] = true
 			}
+			for _, and := range flag.And {
+				andGroupSet[and] = true
+			}
 		}
 		if !flag.Required || flag.Set {
 			continue
 		}
-		if len(flag.Xor) > 0 {
+		if len(flag.Xor) > 0 || len(flag.And) > 0 {
 			for _, xor := range flag.Xor {
 				if xorGroupSet[xor] {
 					continue
 				}
 				xorGroup[xor] = append(xorGroup[xor], flag.Summary())
 			}
+			for _, and := range flag.And {
+				andGroup[and] = append(andGroup[and], flag.Summary())
+			}
 		} else {
 			missing = append(missing, flag.Summary())
 		}
@@ -857,6 +869,11 @@ func checkMissingFlags(flags []*Flag) error {
 			missing = append(missing, strings.Join(flags, " or "))
 		}
 	}
+	for _, flags := range andGroup {
+		if len(flags) > 1 {
+			missing = append(missing, strings.Join(flags, " and "))
+		}
+	}
 
 	if len(missing) == 0 {
 		return nil
@@ -867,6 +884,18 @@ func checkMissingFlags(flags []*Flag) error {
 	return fmt.Errorf("missing flags: %s", strings.Join(missing, ", "))
 }
 
+func getRequiredAndGroupMap(flags []*Flag) map[string]bool {
+	andGroupRequired := map[string]bool{}
+	for _, flag := range flags {
+		for _, and := range flag.And {
+			if flag.Required {
+				andGroupRequired[and] = true
+			}
+		}
+	}
+	return andGroupRequired
+}
+
 func checkMissingChildren(node *Node) error {
 	missing := []string{}
 
@@ -977,6 +1006,20 @@ func checkPassthroughArg(target reflect.Value) bool {
 	}
 }
 
+func checkXorDuplicatedAndAndMissing(paths []*Path) error {
+	errs := []string{}
+	if err := checkXorDuplicates(paths); err != nil {
+		errs = append(errs, err.Error())
+	}
+	if err := checkAndMissing(paths); err != nil {
+		errs = append(errs, err.Error())
+	}
+	if len(errs) > 0 {
+		return fmt.Errorf(strings.Join(errs, ", "))
+	}
+	return nil
+}
+
 func checkXorDuplicates(paths []*Path) error {
 	for _, path := range paths {
 		seen := map[string]*Flag{}
@@ -995,6 +1038,38 @@ func checkXorDuplicates(paths []*Path) error {
 	return nil
 }
 
+func checkAndMissing(paths []*Path) error {
+	for _, path := range paths {
+		missingMsgs := []string{}
+		andGroups := map[string][]*Flag{}
+		for _, flag := range path.Flags {
+			for _, and := range flag.And {
+				andGroups[and] = append(andGroups[and], flag)
+			}
+		}
+		for _, flags := range andGroups {
+			oneSet := false
+			notSet := []*Flag{}
+			flagNames := []string{}
+			for _, flag := range flags {
+				flagNames = append(flagNames, flag.Name)
+				if flag.Set {
+					oneSet = true
+				} else {
+					notSet = append(notSet, flag)
+				}
+			}
+			if len(notSet) > 0 && oneSet {
+				missingMsgs = append(missingMsgs, fmt.Sprintf("--%s must be used together", strings.Join(flagNames, " and --")))
+			}
+		}
+		if len(missingMsgs) > 0 {
+			return fmt.Errorf("%s", strings.Join(missingMsgs, ", "))
+		}
+	}
+	return nil
+}
+
 func findPotentialCandidates(needle string, haystack []string, format string, args ...interface{}) error {
 	if len(haystack) == 0 {
 		return fmt.Errorf(format, args...)
diff --git a/go.sum b/go.sum
index fe9dd12..c65b342 100644
--- a/go.sum
+++ b/go.sum
@@ -1,15 +1,5 @@
-github.com/alecthomas/assert/v2 v2.4.1 h1:mwPZod/d35nlaCppr6sFP0rbCL05WH9fIo7lvsf47zo=
-github.com/alecthomas/assert/v2 v2.4.1/go.mod h1:fw5suVxB+wfYJ3291t0hRTqtGzFYdSwstnRQdaQx2DM=
-github.com/alecthomas/assert/v2 v2.5.0 h1:OJKYg53BQx06/bMRBSPDCO49CbCDNiUQXwdoNrt6x5w=
-github.com/alecthomas/assert/v2 v2.5.0/go.mod h1:fw5suVxB+wfYJ3291t0hRTqtGzFYdSwstnRQdaQx2DM=
-github.com/alecthomas/assert/v2 v2.6.0 h1:o3WJwILtexrEUk3cUVal3oiQY2tfgr/FHWiz/v2n4FU=
-github.com/alecthomas/assert/v2 v2.6.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
-github.com/alecthomas/assert/v2 v2.8.1 h1:YCxnYR6jjpfnEK5AK5SysALKdUEBPGH4Y7As6tBnDw0=
-github.com/alecthomas/assert/v2 v2.8.1/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
 github.com/alecthomas/assert/v2 v2.10.0 h1:jjRCHsj6hBJhkmhznrCzoNpbA3zqy0fYiUcYZP/GkPY=
 github.com/alecthomas/assert/v2 v2.10.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
-github.com/alecthomas/repr v0.3.0 h1:NeYzUPfjjlqHY4KtzgKJiWd6sVq2eNUPTi34PiFGjY8=
-github.com/alecthomas/repr v0.3.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
 github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
 github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
 github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
diff --git a/model.go b/model.go
index 8d1f82f..3190f3e 100644
--- a/model.go
+++ b/model.go
@@ -405,6 +405,7 @@ type Flag struct {
 	*Value
 	Group       *Group // Logical grouping when displaying. May also be used by configuration loaders to group options logically.
 	Xor         []string
+	And         []string
 	PlaceHolder string
 	Envs        []string
 	Aliases     []string
diff --git a/tag.go b/tag.go
index 3e37c19..39be2e3 100644
--- a/tag.go
+++ b/tag.go
@@ -32,6 +32,7 @@ type Tag struct {
 	Enum        string
 	Group       string
 	Xor         []string
+	And         []string
 	Vars        Vars
 	Prefix      string // Optional prefix on anonymous structs. All sub-flags will have this prefix.
 	EnvPrefix   string
@@ -249,6 +250,9 @@ func hydrateTag(t *Tag, typ reflect.Type) error { //nolint: gocyclo
 	for _, xor := range t.GetAll("xor") {
 		t.Xor = append(t.Xor, strings.FieldsFunc(xor, tagSplitFn)...)
 	}
+	for _, and := range t.GetAll("and") {
+		t.And = append(t.And, strings.FieldsFunc(and, tagSplitFn)...)
+	}
 	t.Prefix = t.Get("prefix")
 	t.EnvPrefix = t.Get("envprefix")
 	t.Embed = t.Has("embed")


Test patch:
diff --git a/kong_test.go b/kong_test.go
index f3fb869..0f0bb86 100644
--- a/kong_test.go
+++ b/kong_test.go
@@ -4,6 +4,7 @@ import (
 	"bytes"
 	"errors"
 	"fmt"
+	"sort"
 	"strings"
 	"testing"
 
@@ -919,6 +920,21 @@ func TestXor(t *testing.T) {
 	assert.NoError(t, err)
 }
 
+func TestAnd(t *testing.T) {
+	var cli struct {
+		Hello bool   `and:"another"`
+		One   bool   `and:"group"`
+		Two   string `and:"group"`
+	}
+	p := mustNew(t, &cli)
+	_, err := p.Parse([]string{"--hello", "--one"})
+	assert.EqualError(t, err, "--one and --two must be used together")
+
+	p = mustNew(t, &cli)
+	_, err = p.Parse([]string{"--one", "--two=hi", "--hello"})
+	assert.NoError(t, err)
+}
+
 func TestXorChild(t *testing.T) {
 	var cli struct {
 		One bool `xor:"group"`
@@ -936,6 +952,23 @@ func TestXorChild(t *testing.T) {
 	assert.Error(t, err, "--two and --three can't be used together")
 }
 
+func TestAndChild(t *testing.T) {
+	var cli struct {
+		One bool `and:"group"`
+		Cmd struct {
+			Two   string `and:"group"`
+			Three string `and:"group"`
+		} `cmd`
+	}
+	p := mustNew(t, &cli)
+	_, err := p.Parse([]string{"--one", "cmd", "--two=hi", "--three=hello"})
+	assert.NoError(t, err)
+
+	p = mustNew(t, &cli)
+	_, err = p.Parse([]string{"--two=hi", "cmd"})
+	assert.Error(t, err, "--two and --three must be used together")
+}
+
 func TestMultiXor(t *testing.T) {
 	var cli struct {
 		Hello bool   `xor:"one,two"`
@@ -952,6 +985,47 @@ func TestMultiXor(t *testing.T) {
 	assert.EqualError(t, err, "--hello and --two can't be used together")
 }
 
+func TestMultiAnd(t *testing.T) {
+	var cli struct {
+		Hello bool   `and:"one,two"`
+		One   bool   `and:"one"`
+		Two   string `and:"two"`
+	}
+
+	p := mustNew(t, &cli)
+	_, err := p.Parse([]string{"--hello"})
+	// Split and combine error so messages always will be in the same order
+	// when testing
+	missingMsgs := strings.Split(err.Error(), ", ")
+	sort.Strings(missingMsgs)
+	err = fmt.Errorf("%s", strings.Join(missingMsgs, ", "))
+	assert.EqualError(t, err, "--hello and --one must be used together, --hello and --two must be used together")
+
+	p = mustNew(t, &cli)
+	_, err = p.Parse([]string{"--two=foo"})
+	assert.EqualError(t, err, "--hello and --two must be used together")
+}
+
+func TestXorAnd(t *testing.T) {
+	var cli struct {
+		Hello bool   `xor:"one" and:"two"`
+		One   bool   `xor:"one"`
+		Two   string `and:"two"`
+	}
+
+	p := mustNew(t, &cli)
+	_, err := p.Parse([]string{"--hello"})
+	assert.EqualError(t, err, "--hello and --two must be used together")
+
+	p = mustNew(t, &cli)
+	_, err = p.Parse([]string{"--one"})
+	assert.NoError(t, err)
+
+	p = mustNew(t, &cli)
+	_, err = p.Parse([]string{"--hello", "--one"})
+	assert.EqualError(t, err, "--hello and --one can't be used together, --hello and --two must be used together")
+}
+
 func TestXorRequired(t *testing.T) {
 	var cli struct {
 		One   bool `xor:"one,two" required:""`
@@ -972,6 +1046,26 @@ func TestXorRequired(t *testing.T) {
 	assert.EqualError(t, err, "missing flags: --four, --one or --three, --one or --two")
 }
 
+func TestAndRequired(t *testing.T) {
+	var cli struct {
+		One   bool `and:"one,two" required:""`
+		Two   bool `and:"one" required:""`
+		Three bool `and:"two"`
+		Four  bool `required:""`
+	}
+	p := mustNew(t, &cli)
+	_, err := p.Parse([]string{"--one", "--two", "--three"})
+	assert.EqualError(t, err, "missing flags: --four")
+
+	p = mustNew(t, &cli)
+	_, err = p.Parse([]string{"--four"})
+	assert.EqualError(t, err, "missing flags: --one and --three, --one and --two")
+
+	p = mustNew(t, &cli)
+	_, err = p.Parse([]string{})
+	assert.EqualError(t, err, "missing flags: --four, --one and --three, --one and --two")
+}
+
 func TestXorRequiredMany(t *testing.T) {
 	var cli struct {
 		One   bool `xor:"one" required:""`
@@ -991,6 +1085,21 @@ func TestXorRequiredMany(t *testing.T) {
 	assert.EqualError(t, err, "missing flags: --one or --two or --three")
 }
 
+func TestAndRequiredMany(t *testing.T) {
+	var cli struct {
+		One   bool `and:"one" required:""`
+		Two   bool `and:"one" required:""`
+		Three bool `and:"one" required:""`
+	}
+	p := mustNew(t, &cli)
+	_, err := p.Parse([]string{})
+	assert.EqualError(t, err, "missing flags: --one and --two and --three")
+
+	p = mustNew(t, &cli)
+	_, err = p.Parse([]string{"--three"})
+	assert.EqualError(t, err, "missing flags: --one and --two")
+}
+
 func TestEnumSequence(t *testing.T) {
 	var cli struct {
 		State []string `enum:"a,b,c" default:"a"`