diff --git a/pkg/cmdopt/cmdopt.go b/pkg/cmdopt/cmdopt.go new file mode 100644 index 00000000..687744c5 --- /dev/null +++ b/pkg/cmdopt/cmdopt.go @@ -0,0 +1,274 @@ +// Copyright (c) The Observatorium Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Used from https://github.com/observatorium/observatorium/blob/main/configuration_go/kubegen/cmdopt/cmdopt.go + +package cmdopt + +import ( + "fmt" + "log" + "reflect" + "strings" +) + +const ( + tagName = "opt" + noValModifier = "noval" + singleHyphenModifier = "single-hyphen" +) + +type CmdOptions struct{} + +// GetOpts is used to generate command line options from a struct +// by mapping the fields values to the option name defined in the opt tag (first position). +// GetOpts returns a slice of strings, each string representing an option. +// Following types are supported: string, int, bool, float64, time.Duration, slice of supported types, sub struct implementing the Stringer interface. +// Pointer types are used if not nil. Private fields are ignored. +// Additional tags can be added to the opt tag, separated by a comma, to modify its default behavior: +// - noval: the option is added without a value if the field is true. +// - single-hyphen: the option is prefixed with a single hyphen instead of a double hyphen. +func GetOpts(obj interface{}) []string { + ret := []string{} + + // if obj is nil, return empty slice + if obj == nil { + return ret + } + + // Extract extra options using ExtraOpts interface if it is implemented. + // Append them to the result slice at the end. + var extraOpts []string + getExtraOptsMethod := reflect.ValueOf(obj).MethodByName("GetExtraOpts") + if getExtraOptsMethod.IsValid() { + result := getExtraOptsMethod.Call(nil) + if len(result) > 0 { + extraOpts, _ = result[0].Interface().([]string) + } + } + + // if obj is a pointer, dereference it + if reflect.TypeOf(obj).Kind() == reflect.Ptr { + obj = reflect.ValueOf(obj).Elem().Interface() + } + + t := reflect.TypeOf(obj) + v := reflect.ValueOf(obj) + + for i := 0; i < t.NumField(); i++ { + fieldKind := t.Field(i).Type.Kind() + fieldValue := v.Field(i) + + // If the field is not exported, skip it. + if t.Field(i).PkgPath != "" { + continue + } + + optTagVals := strings.Split(t.Field(i).Tag.Get(tagName), ",") + if len(optTagVals) == 0 { + continue + } + + optName := getOptName(optTagVals) + if optName == "" { + continue + } + + // Check if noval modifier is set. + if isNoVal(optTagVals[0:], fieldKind, fieldValue) { + ret = append(ret, optName) + continue + } + + optValue := getOptValue(fieldKind, fieldValue) + if len(optValue) == 0 { + continue + } + + for _, v := range optValue { + ret = append(ret, fmt.Sprintf("%s=%s", optName, v)) + } + } + + ret = append(ret, extraOpts...) + + return ret +} + +func getOptName(opt []string) string { + + switch len(opt[0]) { + case 0: + return "" + case 1: + return "-" + opt[0] + default: + if isSingleHyphen(opt[1:]) { + return "-" + opt[0] + } + + return "--" + opt[0] + } +} + +func getOptValue(kind reflect.Kind, rValue reflect.Value) []string { + ret := []string{} + wasPtr := false + + // If pointer type and nil, skip it, otherwise dereference it. + if kind == reflect.Ptr { + if rValue.IsNil() { + return ret + } + + // Check if Stringer interface is implemented on pointer receiver. + if str := getStringerValue(rValue); str != "" { + ret = append(ret, str) + return ret + } + + rValue = rValue.Elem() + kind = rValue.Kind() + wasPtr = true + } + + switch kind { + case reflect.String: + value := rValue.String() + if value == "" && !wasPtr { + return ret + } + + ret = append(ret, value) + case reflect.Int: + value := rValue.Int() + if value == 0 && !wasPtr { + return ret + } + + ret = append(ret, fmt.Sprintf("%d", value)) + case reflect.Bool: + value := rValue.Bool() + if !value && !wasPtr { + return ret + } + + ret = append(ret, fmt.Sprintf("%t", value)) + case reflect.Float64: + value := rValue.Float() + if value == 0 && !wasPtr { + return ret + } + + res := fmt.Sprintf("%.2f", value) + res = strings.TrimRight(res, "0") + res = strings.TrimRight(res, ".") + ret = append(ret, res) + case reflect.Struct, reflect.Int64: // Int64 for time.Duration + if kind == reflect.Int64 { + if rValue.Int() == 0 && !wasPtr { + return ret + } + } + + // Check if Stringer interface is implemented on struct receiver. + str := getStringerValue(rValue) + if str == "" { + return ret + } + + ret = append(ret, str) + + case reflect.Slice: + if rValue.Len() == 0 { + return ret + } + + // get slice values recursively + for i := 0; i < rValue.Len(); i++ { + ret = append(ret, getOptValue(rValue.Index(i).Kind(), rValue.Index(i))...) + } + // use stringer interface if interface + case reflect.Interface: + str := getStringerValue(rValue) + if str == "" { + return ret + } + + ret = append(ret, str) + default: + log.Printf("unsupported type %q by cmdopt is ignored", kind) + } + + return ret +} + +func getStringerValue(rValue reflect.Value) string { + str, ok := rValue.Interface().(fmt.Stringer) + if !ok { + return "" + } + + return str.String() +} + +func isNoVal(optVals []string, kind reflect.Kind, v reflect.Value) bool { + // If pointer type and nil, skip it, otherwise dereference it. + if kind == reflect.Ptr { + if v.IsNil() { + return false + } + + v = v.Elem() + kind = v.Kind() + } + + for _, optVal := range optVals { + if optVal == noValModifier && kind == reflect.Bool && v.Bool() { + return true + } + } + + return false +} + +func isSingleHyphen(optVals []string) bool { + for _, optVal := range optVals { + if optVal == singleHyphenModifier { + return true + } + } + + return false +} + +// ExtraOpts is a struct that can be embedded in a struct to add extra options. +// These options can be used without exposing them in the struct. +type ExtraOpts struct { + opts []string +} + +// AddOpts adds extra options to the struct. +func (e *ExtraOpts) AddExtraOpts(s ...string) { + e.opts = append(e.opts, s...) +} + +// GetExtraOpts returns the extra options. +func (e *ExtraOpts) GetExtraOpts() []string { + return e.opts +} + +// DeleteExtraOpts deletes the extra options. +func (e *ExtraOpts) DeleteExtraOpts() { + e.opts = []string{} +} diff --git a/pkg/cmdopt/cmdopt_test.go b/pkg/cmdopt/cmdopt_test.go new file mode 100644 index 00000000..c28d8d24 --- /dev/null +++ b/pkg/cmdopt/cmdopt_test.go @@ -0,0 +1,335 @@ +package cmdopt + +import ( + "fmt" + "testing" + "time" +) + +type SubStruct struct { + SubString string +} + +func (s SubStruct) String() string { + return s.SubString +} + +type SubStructPtr struct { + SubString string +} + +func (s *SubStructPtr) String() string { + return s.SubString +} + +type Dummy struct{} + +func (d Dummy) GoString() string { + return "dummy" +} + +type TestOptions struct { + String string `opt:"string"` + Int int `opt:"int"` // Zero value is ignored + IntPtr *int `opt:"intptr"` // Zero value is not ignored + Float float64 `opt:"float"` + FloatPtr *float64 `opt:"floatptr"` + Bool bool `opt:"bool"` + BoolPtr *bool `opt:"bool"` + Duration time.Duration `opt:"duration"` + DurationPtr *time.Duration `opt:"duration"` + Sub SubStruct `opt:"sub"` + SubPtr *SubStructPtr `opt:"subptr"` + NoValue bool `opt:"no-value,noval"` + Repeat []string `opt:"repeat"` + SingleHyphen int `opt:"single,single-hyphen"` + Interface fmt.Stringer `opt:"stringer"` + + // Limits tests + DoubleType string `opt:"string,int"` + NovalWithoutBoolType float64 `opt:"nobool,noval"` + PointerToSupportedType *string `opt:"string"` + DoublePointer **string `opt:"string"` + RepeatToPointer []*string `opt:"repeat"` + EmptyTag string `opt:""` + OtherTagName string `opts:"other"` + InvalidInterface fmt.GoStringer `opt:"stringer"` + + privateField string `opt:"string"` +} + +func TestCmdOptions(t *testing.T) { + myStringPointer := &[]string{"string"}[0] + + testCases := map[string]struct { + options TestOptions + expect []string + }{ + "empty": { + options: TestOptions{}, + expect: []string{}, + }, + "string": { + options: TestOptions{ + String: "string", + }, + expect: []string{"--string=string"}, + }, + "int": { + options: TestOptions{ + Int: 1, + }, + expect: []string{"--int=1"}, + }, + "int-zero": { + options: TestOptions{ + Int: 0, + }, + expect: []string{}, + }, + "int-ptr-zero": { + options: TestOptions{ + IntPtr: new(int), + }, + expect: []string{"--intptr=0"}, + }, + "float": { + options: TestOptions{ + Float: 1.1, + }, + expect: []string{"--float=1.1"}, + }, + "float-zero": { + options: TestOptions{ + Float: 0, + }, + expect: []string{}, + }, + "float-ptr-zero": { + options: TestOptions{ + FloatPtr: new(float64), + }, + expect: []string{"--floatptr=0"}, + }, + "bool": { + options: TestOptions{ + Bool: true, + }, + expect: []string{"--bool=true"}, + }, + "bool-false": { + options: TestOptions{ + Bool: false, + }, + expect: []string{}, + }, + "bool-ptr-false": { + options: TestOptions{ + BoolPtr: new(bool), + }, + expect: []string{"--bool=false"}, + }, + "duration": { + options: TestOptions{ + Duration: time.Second, + }, + expect: []string{"--duration=1s"}, + }, + "duration-zero": { + options: TestOptions{ + Duration: 0, + }, + expect: []string{}, + }, + "duration-ptr-zero": { + options: TestOptions{ + DurationPtr: new(time.Duration), + }, + expect: []string{"--duration=0s"}, + }, + "sub": { + options: TestOptions{ + Sub: SubStruct{ + SubString: "sub", + }, + }, + expect: []string{"--sub=sub"}, + }, + "sub-zero": { + options: TestOptions{ + Sub: SubStruct{}, + }, + expect: []string{}, + }, + "sub-ptr-zero": { + options: TestOptions{ + SubPtr: &SubStructPtr{}, + }, + expect: []string{}, + }, + "sub with stringer interface on pointer receiver": { + options: TestOptions{ + SubPtr: &SubStructPtr{ + SubString: "sub", + }, + }, + expect: []string{"--subptr=sub"}, + }, + "no-value": { + options: TestOptions{ + NoValue: true, + }, + expect: []string{"--no-value"}, + }, + "repeat": { + options: TestOptions{ + Repeat: []string{"repeat1", "repeat2"}, + }, + expect: []string{"--repeat=repeat1", "--repeat=repeat2"}, + }, + "single-hyphen": { + options: TestOptions{ + SingleHyphen: 1, + }, + expect: []string{"-single=1"}, + }, + "many": { + options: TestOptions{ + String: "string", + Int: 1, + }, + expect: []string{"--string=string", "--int=1"}, + }, + "double type ignored": { + options: TestOptions{ + DoubleType: "string", + }, + expect: []string{"--string=string"}, + }, + "noval without bool type ignored": { + options: TestOptions{ + NovalWithoutBoolType: 1.1, + }, + expect: []string{"--nobool=1.1"}, + }, + "pointer to supported type": { + options: TestOptions{ + PointerToSupportedType: &[]string{"string"}[0], + }, + expect: []string{"--string=string"}, + }, + "nil pointer to supported type": { + options: TestOptions{ + PointerToSupportedType: nil, + }, + expect: []string{}, + }, + "private field ignored": { + options: TestOptions{ + privateField: "string", + }, + expect: []string{}, + }, + "double pointer ignored": { + options: TestOptions{ + DoublePointer: &myStringPointer, + }, + expect: []string{}, + }, + "repeat to pointer": { + options: TestOptions{ + RepeatToPointer: []*string{myStringPointer}, + }, + expect: []string{"--repeat=string"}, + }, + "empty tag ignored": { + options: TestOptions{ + EmptyTag: "string", + }, + expect: []string{}, + }, + "other tag ignored": { + options: TestOptions{ + OtherTagName: "string", + }, + expect: []string{}, + }, + "interface with stringer": { + options: TestOptions{ + Interface: SubStruct{ + SubString: "sub", + }, + }, + expect: []string{"--stringer=sub"}, + }, + "interface without stringer": { + options: TestOptions{ + InvalidInterface: Dummy{}, + }, + expect: []string{}, + }, + } + + t.Parallel() + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + args := GetOpts(tc.options) + if len(args) != len(tc.expect) { + t.Fatalf("expected %d args, got %d: %s", len(tc.expect), len(args), args) + } + + for i := range args { + if args[i] != tc.expect[i] { + t.Fatalf("expected %s, got %s", tc.expect[i], args[i]) + } + } + }) + } +} + +// TestUnsupportedType tests that usupported types are ignored. +// Done in a separate test because it generates a log message. +func TestUnsupportedType(t *testing.T) { + unsupportedType := struct { + Chan chan int `opt:"chan"` + }{ + Chan: make(chan int), + } + + args := GetOpts(&unsupportedType) + if len(args) != 0 { + t.Fatalf("expected 0 args, got %d: %s", len(args), args) + } +} + +func TestCmdOptionsExtraOpts(t *testing.T) { + objWithExtraOpts := struct { + String string `opt:"string"` + ExtraOpts + }{ + String: "string", + } + + objWithExtraOpts.AddExtraOpts("--extra1", "--extra2") + + expected := []string{"--string=string", "--extra1", "--extra2"} + + args := GetOpts(&objWithExtraOpts) + if len(args) != 3 { + t.Fatalf("expected 3 args, got %d: %s", len(args), args) + } + + for i := range args { + if args[i] != expected[i] { + t.Fatalf("expected %s, got %s", expected[i], args[i]) + } + } + + objWithExtraOpts.DeleteExtraOpts() + + args = GetOpts(&objWithExtraOpts) + if len(args) != 1 { + t.Fatalf("expected 1 args, got %d: %s", len(args), args) + } +}