diff --git a/.golangci.yml b/.golangci.yml index 3261131..66453fb 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -40,6 +40,7 @@ issues: - funlen - goerr113 - dupl + - maintidx linters: # please, do not use `enable-all`: it's deprecated and will be removed soon. diff --git a/config/config.go b/config/config.go index ec9789f..06763c7 100644 --- a/config/config.go +++ b/config/config.go @@ -21,6 +21,7 @@ import ( "errors" "fmt" "reflect" + "slices" "strconv" "strings" "time" @@ -49,8 +50,10 @@ func (c Config) Sanitize() Config { // configuration, the default value is applied. func (c Config) ApplyDefaults(params Parameters) Config { for key, param := range params { - if strings.TrimSpace(c[key]) == "" { - c[key] = param.Default + for _, key := range c.getKeysForParameter(key) { + if strings.TrimSpace(c[key]) == "" { + c[key] = param.Default + } } } return c @@ -83,7 +86,24 @@ func (c Config) Validate(params Parameters) error { func (c Config) validateUnrecognizedParameters(params Parameters) []error { var errs []error for key := range c { - if _, ok := params[key]; !ok { + if _, ok := params[key]; ok { + // Direct match. + continue + } + // Check if the key is a wildcard key. + match := false + for pattern := range params { + if !strings.Contains(pattern, "*") { + continue + } + // Check if the key matches the wildcard key. + if c.matchParameterKey(key, pattern) { + match = true + break + } + } + + if !match { errs = append(errs, fmt.Errorf("%q: %w", key, ErrUnrecognizedParameter)) } } @@ -92,62 +112,152 @@ func (c Config) validateUnrecognizedParameters(params Parameters) []error { // validateParamType validates that a parameter value is parsable to its assigned type. func (c Config) validateParamType(key string, param Parameter) error { - value := c[key] - // empty value is valid for all types - if c[key] == "" { - return nil - } + keys := c.getKeysForParameter(key) - //nolint:exhaustive // type ParameterTypeFile and ParameterTypeString don't need type validations (both are strings or byte slices) - switch param.Type { - case ParameterTypeInt: - _, err := strconv.Atoi(value) - if err != nil { - return fmt.Errorf("error validating %q: %q value is not an integer: %w", key, value, ErrInvalidParameterType) - } - case ParameterTypeFloat: - _, err := strconv.ParseFloat(value, 64) - if err != nil { - return fmt.Errorf("error validating %q: %q value is not a float: %w", key, value, ErrInvalidParameterType) - } - case ParameterTypeDuration: - _, err := time.ParseDuration(value) - if err != nil { - return fmt.Errorf("error validating %q: %q value is not a duration: %w", key, value, ErrInvalidParameterType) + var errs []error + for _, k := range keys { + value := c[k] + // empty value is valid for all types + if value == "" { + continue } - case ParameterTypeBool: - _, err := strconv.ParseBool(value) - if err != nil { - return fmt.Errorf("error validating %q: %q value is not a boolean: %w", key, value, ErrInvalidParameterType) + //nolint:exhaustive // type ParameterTypeFile and ParameterTypeString don't need type validations (both are strings or byte slices) + switch param.Type { + case ParameterTypeInt: + _, err := strconv.Atoi(value) + if err != nil { + errs = append(errs, fmt.Errorf("error validating %q: %q value is not an integer: %w", k, value, ErrInvalidParameterType)) + } + case ParameterTypeFloat: + _, err := strconv.ParseFloat(value, 64) + if err != nil { + errs = append(errs, fmt.Errorf("error validating %q: %q value is not a float: %w", k, value, ErrInvalidParameterType)) + } + case ParameterTypeDuration: + _, err := time.ParseDuration(value) + if err != nil { + errs = append(errs, fmt.Errorf("error validating %q: %q value is not a duration: %w", k, value, ErrInvalidParameterType)) + } + case ParameterTypeBool: + _, err := strconv.ParseBool(value) + if err != nil { + errs = append(errs, fmt.Errorf("error validating %q: %q value is not a boolean: %w", k, value, ErrInvalidParameterType)) + } } } - return nil + return errors.Join(errs...) } // validateParamValue validates that a configuration value matches all the // validations required for the parameter. func (c Config) validateParamValue(key string, param Parameter) error { - value := c[key] var errs []error + for _, k := range c.getKeysForParameter(key) { + value := c[k] + var valErrs []error - isRequired := false - for _, v := range param.Validations { - if _, ok := v.(ValidationRequired); ok { - isRequired = true + isRequired := false + for _, v := range param.Validations { + if _, ok := v.(ValidationRequired); ok { + isRequired = true + } + err := v.Validate(value) + if err != nil { + valErrs = append(valErrs, fmt.Errorf("error validating %q: %w", k, err)) + continue + } } - err := v.Validate(value) - if err != nil { - errs = append(errs, fmt.Errorf("error validating %q: %w", key, err)) - continue + if value == "" && !isRequired { + continue // empty optional parameter is valid } - } - if value == "" && !isRequired { - return nil // empty optional parameter is valid + errs = append(errs, valErrs...) } return errors.Join(errs...) } +func (c Config) getKeysForParameter(key string) []string { + // First break up the key into tokens. + tokens := strings.Split(key, "*") + if len(tokens) == 1 { + // No wildcard in the key, return the key directly. + return []string{key} + } + + // There is at least one wildcard in the key, we need to manually find all + // the keys that match the pattern. + var keys []string + for k := range c { + fullKey := k + for i, token := range tokens { + if i == len(tokens)-1 { + if k == "" && token != "" { + // The key is consumed, but the token is not, it does not match the pattern. + // This happens when the last token is not a wildcard and + // the key is a leaf. + // e.g. param: "collection.*.format", key: "collection.foo" + break + } + // The last token doesn't matter, if the key matched so far, all + // wildcards have matched and we can potentially expect a match. + // The reason for this is so that we can apply defaults to the + // wildcard keys, even if they don't contain a value in the + // configuration. + if token != "" { + // Build potential key + fullKey = strings.TrimSuffix(fullKey, k) + fullKey += token + } + keys = append(keys, fullKey) + break + } + + var ok bool + k, ok = consume(k, token) + if !ok { + // The key does not start with the token, it does not match the pattern. + break + } + + // Between tokens there is a wildcard, we need to consume the key + // until the next ".". If there is no next ".", the whole key is + // consumed. + // e.g. "foo.format" -> ".format" or "foo" -> "" + if index := strings.IndexRune(k, '.'); index != -1 { + k = k[index:] + } else { + k = "" + } + } + } + slices.Sort(keys) + return slices.Compact(keys) +} + +func (c Config) matchParameterKey(key, pattern string) bool { + tokens := strings.Split(pattern, "*") + if len(tokens) == 1 { + // No wildcard in the key, compare the key directly. + return key == pattern + } + k := key + for _, token := range tokens { + var ok bool + k, ok = consume(k, token) + if !ok { + return false + } + + // Between tokens there is a wildcard, we need to strip the key until + // the next ".". + _, k, ok = strings.Cut(k, ".") + if ok { + k = "." + k // Add the "." back to the key. + } + } + return true +} + // DecodeInto copies configuration values into the target object. // Under the hood, this function uses github.com/mitchellh/mapstructure, with // the "mapstructure" tag renamed to "json". To rename a key, use the "json" @@ -160,6 +270,8 @@ func (c Config) DecodeInto(target any, hookFunc ...mapstructure.DecodeHookFunc) DecodeHook: mapstructure.ComposeDecodeHookFunc( append( hookFunc, + mapStringHookFunc(), + mapStructHookFunc(), emptyStringToZeroValueHookFunc(), mapstructure.StringToTimeDurationHookFunc(), mapstructure.StringToSliceHookFunc(","), @@ -215,3 +327,63 @@ func emptyStringToZeroValueHookFunc() mapstructure.DecodeHookFunc { return reflect.New(t).Elem().Interface(), nil } } + +func mapStringHookFunc() mapstructure.DecodeHookFunc { + return func( + f reflect.Type, + t reflect.Type, + data interface{}, + ) (interface{}, error) { + if f.Kind() != reflect.Map || f.Elem().Kind() != reflect.Interface || + t.Kind() != reflect.Map || t.Elem().Kind() != reflect.String { + return data, nil + } + + //nolint:forcetypeassert // We checked in the condition above and know it's a map[string]any + dataMap := data.(map[string]any) + + // remove all keys with maps + for k, v := range dataMap { + if reflect.TypeOf(v).Kind() == reflect.Map { + delete(dataMap, k) + } + } + + return dataMap, nil + } +} + +func mapStructHookFunc() mapstructure.DecodeHookFunc { + return func( + f reflect.Type, + t reflect.Type, + data interface{}, + ) (interface{}, error) { + if f.Kind() != reflect.Map || f.Elem().Kind() != reflect.Interface || + t.Kind() != reflect.Map || t.Elem().Kind() != reflect.Struct { + return data, nil + } + + //nolint:forcetypeassert // We checked in the condition above and know it's a map[string]any + dataMap := data.(map[string]any) + + // remove all keys with a dot that contains a value with a string + for k, v := range dataMap { + _, isString := v.(string) + if !isString || !strings.Contains(k, ".") { + continue + } + delete(dataMap, k) + } + + return dataMap, nil + } +} + +func consume(s, prefix string) (string, bool) { + if !strings.HasPrefix(s, prefix) { + // The key does not start with the token, it does not match the pattern. + return "", false + } + return strings.TrimPrefix(s, prefix), true +} diff --git a/config/config_test.go b/config/config_test.go index 217974a..b554591 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -17,9 +17,11 @@ package config import ( "errors" "regexp" + "sort" "testing" "time" + "github.com/google/go-cmp/cmp" "github.com/matryer/is" ) @@ -103,6 +105,72 @@ func TestConfig_Validate_ParameterType(t *testing.T) { config: Config{"param1": "some-data"}, params: Parameters{"param1": {Type: ParameterTypeFile}}, wantErr: false, + }, { + // ---------------------- DYNAMIC PARAMETER TESTS ---------------------- + name: "dynamic: valid type number", + config: Config{"foo.0.param1": "3"}, + params: Parameters{"foo.*.param1": {Default: "3.3", Type: ParameterTypeFloat}}, + wantErr: false, + }, { + name: "dynamic: invalid type float", + config: Config{"foo.0.param1": "not-a-number"}, + params: Parameters{"foo.*.param1": {Default: "3.3", Type: ParameterTypeFloat}}, + wantErr: true, + }, { + name: "dynamic: valid default type float", + config: Config{"foo.0.param1": ""}, + params: Parameters{"foo.*.param1": {Default: "3", Type: ParameterTypeFloat}}, + wantErr: false, + }, { + name: "dynamic: valid type int", + config: Config{"foo.0.param1": "3"}, + params: Parameters{"foo.*.param1": {Type: ParameterTypeInt}}, + wantErr: false, + }, { + name: "dynamic: invalid type int", + config: Config{"foo.0.param1": "3.3"}, + params: Parameters{"foo.*.param1": {Type: ParameterTypeInt}}, + wantErr: true, + }, { + name: "dynamic: valid type bool", + config: Config{"foo.0.param1": "1"}, + params: Parameters{"foo.*.param1": {Type: ParameterTypeBool}}, + wantErr: false, + }, { + name: "dynamic: valid type bool", + config: Config{"foo.0.param1": "true"}, + params: Parameters{"foo.*.param1": {Type: ParameterTypeBool}}, + wantErr: false, + }, { + name: "dynamic: invalid type bool", + config: Config{"foo.0.param1": "not-a-bool"}, + params: Parameters{"foo.*.param1": {Type: ParameterTypeBool}}, + wantErr: true, + }, { + name: "dynamic: valid type duration", + config: Config{"foo.0.param1": "1s"}, + params: Parameters{"foo.*.param1": {Type: ParameterTypeDuration}}, + wantErr: false, + }, { + name: "dynamic: empty value is valid for all types", + config: Config{"foo.0.param1": ""}, + params: Parameters{"foo.*.param1": {Type: ParameterTypeDuration}}, + wantErr: false, + }, { + name: "dynamic: invalid type duration", + config: Config{"foo.0.param1": "not-a-duration"}, + params: Parameters{"foo.*.param1": {Type: ParameterTypeDuration}}, + wantErr: true, + }, { + name: "dynamic: valid type string", + config: Config{"foo.0.param1": "param"}, + params: Parameters{"foo.*.param1": {Type: ParameterTypeString}}, + wantErr: false, + }, { + name: "dynamic: valid type file", + config: Config{"foo.0.param1": "some-data"}, + params: Parameters{"foo.*.param1": {Type: ParameterTypeFile}}, + wantErr: false, }} for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -114,7 +182,7 @@ func TestConfig_Validate_ParameterType(t *testing.T) { if err != nil && tt.wantErr { is.True(errors.Is(err, ErrInvalidParameterType)) } else if err != nil || tt.wantErr { - t.Errorf("UtilityFunc() error = %v, wantErr %v", err, tt.wantErr) + t.Errorf("error = %v, wantErr %v", err, tt.wantErr) } }) } @@ -179,7 +247,7 @@ func TestConfig_Validate_Validations(t *testing.T) { wantErr: true, err: ErrGreaterThanValidationFail, }, { - name: "greater than validation failed", + name: "greater than validation pass", config: Config{"param1": "20"}, params: Parameters{ "param1": {Validations: []Validation{ @@ -264,6 +332,144 @@ func TestConfig_Validate_Validations(t *testing.T) { }}, }, wantErr: false, + }, { + // ---------------------- DYNAMIC PARAMETER TESTS ---------------------- + name: "dynamic: required validation failed", + config: Config{"foo.0.param1": ""}, + params: Parameters{ + "foo.*.param1": {Validations: []Validation{ + ValidationRequired{}, + }}, + }, + wantErr: true, + err: ErrRequiredParameterMissing, + }, { + name: "dynamic: required validation pass", + config: Config{"foo.0.param1": "value"}, + params: Parameters{ + "foo.*.param1": {Validations: []Validation{ + ValidationRequired{}, + }}, + }, + wantErr: false, + }, { + name: "dynamic: less than validation failed", + config: Config{"foo.0.param1": "20"}, + params: Parameters{ + "foo.*.param1": {Validations: []Validation{ + ValidationRequired{}, + ValidationLessThan{10}, + }}, + }, + wantErr: true, + err: ErrLessThanValidationFail, + }, { + name: "dynamic: less than validation pass", + config: Config{"foo.0.param1": "0"}, + params: Parameters{ + "foo.*.param1": {Validations: []Validation{ + ValidationRequired{}, + ValidationLessThan{10}, + }}, + }, + wantErr: false, + }, { + name: "dynamic: greater than validation failed", + config: Config{"foo.0.param1": "0"}, + params: Parameters{ + "foo.*.param1": {Validations: []Validation{ + ValidationRequired{}, + ValidationGreaterThan{10}, + }}, + }, + wantErr: true, + err: ErrGreaterThanValidationFail, + }, { + name: "dynamic: greater than validation pass", + config: Config{"foo.0.param1": "20"}, + params: Parameters{ + "foo.*.param1": {Validations: []Validation{ + ValidationRequired{}, + ValidationGreaterThan{10}, + }}, + }, + wantErr: false, + }, { + name: "dynamic: inclusion validation failed", + config: Config{"foo.0.param1": "three"}, + params: Parameters{ + "param1": {Validations: []Validation{ + ValidationRequired{}, + ValidationInclusion{[]string{"one", "two"}}, + }}, + }, + wantErr: true, + err: ErrInclusionValidationFail, + }, { + name: "dynamic: inclusion validation pass", + config: Config{"foo.0.param1": "two"}, + params: Parameters{ + "foo.*.param1": {Validations: []Validation{ + ValidationRequired{}, + ValidationInclusion{[]string{"one", "two"}}, + }}, + }, + wantErr: false, + }, { + name: "dynamic: exclusion validation failed", + config: Config{"foo.0.param1": "one"}, + params: Parameters{ + "foo.*.param1": {Validations: []Validation{ + ValidationRequired{}, + ValidationExclusion{[]string{"one", "two"}}, + }}, + }, + wantErr: true, + err: ErrExclusionValidationFail, + }, { + name: "dynamic: exclusion validation pass", + config: Config{"foo.0.param1": "three"}, + params: Parameters{ + "foo.*.param1": {Validations: []Validation{ + ValidationRequired{}, + ValidationExclusion{[]string{"one", "two"}}, + }}, + }, + wantErr: false, + }, { + name: "dynamic: regex validation failed", + config: Config{"foo.0.param1": "a-a"}, + params: Parameters{ + "foo.*.param1": {Validations: []Validation{ + ValidationRequired{}, + ValidationRegex{regexp.MustCompile("[a-z]-[1-9]")}, + }}, + }, + wantErr: true, + err: ErrRegexValidationFail, + }, { + name: "dynamic: regex validation pass", + config: Config{"foo.0.param1": "a-8"}, + params: Parameters{ + "foo.*.param1": {Validations: []Validation{ + ValidationRequired{}, + ValidationRegex{regexp.MustCompile("[a-z]-[1-9]")}, + }}, + }, + wantErr: false, + }, { + name: "dynamic: optional validation pass", + config: Config{"foo.0.param1": ""}, + params: Parameters{ + "foo.*.param1": {Validations: []Validation{ + ValidationInclusion{[]string{"one", "two"}}, + ValidationExclusion{[]string{"three", "four"}}, + ValidationRegex{regexp.MustCompile("[a-z]")}, + ValidationGreaterThan{10}, + ValidationLessThan{20}, + }}, + }, + wantErr: false, }} for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -275,7 +481,45 @@ func TestConfig_Validate_Validations(t *testing.T) { if err != nil && tt.wantErr { is.True(errors.Is(err, tt.err)) } else if err != nil || tt.wantErr { - t.Errorf("UtilityFunc() error = %v, wantErr %v", err, tt.wantErr) + t.Errorf("error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestConfig_Validate_Unrecognized(t *testing.T) { + tests := []struct { + name string + config Config + params Parameters + wantErr bool + }{{ + name: "parameters empty", + config: Config{"param1": "3"}, + params: Parameters{}, + wantErr: true, + }, { + name: "static parameter unrecognized", + config: Config{"param1": "not-a-number"}, + params: Parameters{"param2": {Type: ParameterTypeFloat}}, + wantErr: true, + }, { + name: "dynamic parameter unrecognized", + config: Config{"foo.0.param1.": ""}, + params: Parameters{"foo.*.param2": {Type: ParameterTypeFloat}}, + wantErr: true, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + is := is.New(t) + err := tt.config.Sanitize(). + ApplyDefaults(tt.params). + Validate(tt.params) + + if err != nil && tt.wantErr { + is.True(errors.Is(err, ErrUnrecognizedParameter)) + } else if err != nil || tt.wantErr { + t.Errorf("error = %v, wantErr %v", err, tt.wantErr) } }) } @@ -341,6 +585,74 @@ OUTER: } } +func TestConfig_ApplyDefaults(t *testing.T) { + params := map[string]Parameter{ + "limit": {Type: ParameterTypeInt, Default: "1"}, + "foo.*.param1": {Type: ParameterTypeString, Default: "foo"}, + "foo.*.param2": {Type: ParameterTypeString}, + } + + testCases := []struct { + name string + have Config + want Config + }{{ + name: "empty", + have: Config{}, + want: Config{ + "limit": "1", + }, + }, { + name: "foo.0.param2", + have: Config{ + "foo.0.param2": "bar", + }, + want: Config{ + "limit": "1", + "foo.0.param1": "foo", + "foo.0.param2": "bar", + }, + }, { + name: "foo.0.param1", + have: Config{ + "limit": "-1", + "foo.0.param1": "custom", + }, + want: Config{ + "limit": "-1", + "foo.0.param1": "custom", + "foo.0.param2": "", + }, + }, { + name: "multiple dynamic params", + have: Config{ + "limit": "-1", + "foo.0.param1": "parameter", + "foo.1.param2": "custom", + "foo.2.does-not-exist": "unrecognized key still triggers creation of defaults", + }, + want: Config{ + "limit": "-1", + "foo.0.param1": "parameter", + "foo.0.param2": "", + "foo.1.param1": "foo", + "foo.1.param2": "custom", + "foo.2.param1": "foo", + "foo.2.param2": "", + "foo.2.does-not-exist": "unrecognized key still triggers creation of defaults", + }, + }} + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + is := is.New(t) + got := tc.have.Sanitize(). + ApplyDefaults(params) + is.Equal(tc.want, got) + }) + } +} + // unwrapErrors recursively unwraps all errors combined using errors.Join. func unwrapErrors(err error) []error { errs, ok := err.(interface{ Unwrap() []error }) @@ -429,6 +741,10 @@ func TestParseConfig_Embedded_Struct(t *testing.T) { func TestParseConfig_All_Types(t *testing.T) { is := is.New(t) + type structMapVal struct { + MyString string + MyInt int + } type testCfg struct { MyString string MyBool1 bool @@ -459,6 +775,12 @@ func TestParseConfig_All_Types(t *testing.T) { MySlice []string MyIntSlice []int MyFloatSlice []float32 + + Nested struct { + MyString string + } + StringMap map[string]string + StructMap map[string]structMapVal } input := Config{ @@ -488,6 +810,17 @@ func TestParseConfig_All_Types(t *testing.T) { "myslice": "1,2,3,4", "myIntSlice": "1,2,3,4", "myFloatSlice": "1.1,2.2", + + "nested.mystring": "string", + + "stringmap.foo": "1", + "stringmap.bar": "2", + "stringmap.baz.qux": "3", + + "structmap.foo.mystring": "foo-name", + "structmap.foo.myint": "1", + "structmap.bar.mystring": "bar-name", + "structmap.bar.myint": "-1", } want := testCfg{ MyString: "string", @@ -514,6 +847,16 @@ func TestParseConfig_All_Types(t *testing.T) { MySlice: []string{"1", "2", "3", "4"}, MyIntSlice: []int{1, 2, 3, 4}, MyFloatSlice: []float32{1.1, 2.2}, + Nested: struct{ MyString string }{MyString: "string"}, + StringMap: map[string]string{ + "foo": "1", + "bar": "2", + "baz.qux": "3", + }, + StructMap: map[string]structMapVal{ + "foo": {MyString: "foo-name", MyInt: 1}, + "bar": {MyString: "bar-name", MyInt: -1}, + }, } var result testCfg @@ -557,3 +900,146 @@ func TestBreakUpConfig_Conflict_Value(t *testing.T) { got := input.breakUp() is.Equal(want, got) } + +func TestConfig_getValuesForParameter(t *testing.T) { + cfg := Config{ + "ignore": "me", + "ignore.foo.this": "me", + + // foo + "test.foo.val": "0", + + "test.foo.format.baz.type": "0", + "test.foo.format.baz.options": "0", + + "test.foo.format.qux.type": "0", + "test.foo.format.qux.options": "0", + + // bar + "test.bar.val": "0", + + "test.bar.format.baz.type": "0", + "test.bar.format.baz.options": "0", + + "test.bar.format.qux.type": "0", + "test.bar.format.qux.options": "0", + + // include + "test.include.me": "yes", + + // ignore this, it's not nested + "test.ignore": "0", + } + + testCases := []struct { + key string + want []string + }{{ + key: "test.foo.val", + want: []string{"test.foo.val"}, + }, { + key: "blah", + want: []string{"blah"}, + }, { + key: "test.*.blah", + want: []string{ + // Note that the function returns keys that don't exist in the config, + // it figures out the potential keys based on matched wildcards. + // However, it does not return test.ignore.blah, as test.ignore does + // not contain any nested keys. + "test.foo.blah", + "test.bar.blah", + "test.include.blah", + }, + }, { + key: "test.*", + want: []string{ + "test.foo.val", + "test.foo.format.baz.type", + "test.foo.format.baz.options", + "test.foo.format.qux.type", + "test.foo.format.qux.options", + "test.bar.val", + "test.bar.format.baz.type", + "test.bar.format.baz.options", + "test.bar.format.qux.type", + "test.bar.format.qux.options", + "test.include.me", + "test.ignore", + }, + }, { + key: "test.*.val", + want: []string{ + "test.foo.val", + "test.bar.val", + "test.include.val", + }, + }, { + key: "test.*.format.*", + want: []string{ + "test.foo.format.baz.type", + "test.foo.format.baz.options", + "test.foo.format.qux.type", + "test.foo.format.qux.options", + "test.bar.format.baz.type", + "test.bar.format.baz.options", + "test.bar.format.qux.type", + "test.bar.format.qux.options", + }, + }, { + key: "test.*.format.*.type", + want: []string{ + "test.foo.format.baz.type", + "test.foo.format.qux.type", + "test.bar.format.baz.type", + "test.bar.format.qux.type", + }, + }, { + key: "test.*.format.*.options", + want: []string{ + "test.foo.format.baz.options", + "test.foo.format.qux.options", + "test.bar.format.baz.options", + "test.bar.format.qux.options", + }, + }, { + key: "*", + want: []string{ + "ignore", + "ignore.foo.this", + "test.foo.val", + "test.foo.format.baz.type", + "test.foo.format.baz.options", + "test.foo.format.qux.type", + "test.foo.format.qux.options", + "test.bar.val", + "test.bar.format.baz.type", + "test.bar.format.baz.options", + "test.bar.format.qux.type", + "test.bar.format.qux.options", + "test.include.me", + "test.ignore", + }, + }, { + key: "*.foo.*", + want: []string{ + "ignore.foo.this", + "test.foo.val", + "test.foo.format.baz.type", + "test.foo.format.baz.options", + "test.foo.format.qux.type", + "test.foo.format.qux.options", + }, + }} + + for _, tc := range testCases { + t.Run(tc.key, func(t *testing.T) { + is := is.New(t) + got := cfg.getKeysForParameter(tc.key) + + sort.Strings(tc.want) + sort.Strings(got) + is.Equal(cmp.Diff(tc.want, got), "") + }) + } +}