From fd2f2f0c839fff6dbce2f8476198aa789f49f90f Mon Sep 17 00:00:00 2001 From: marrow16 Date: Sat, 6 Jan 2024 14:15:53 +0000 Subject: [PATCH] Add expand option --- README.md | 31 +++- _examples/expand_option/example.env | 3 + _examples/expand_option/main.go | 20 +++ loader.go | 240 ++++++++++++++++------------ loader_test.go | 95 ++++++++++- options.go | 40 +++++ options_test.go | 74 +++++++++ writer.go | 15 +- writer_test.go | 28 ++++ 9 files changed, 433 insertions(+), 113 deletions(-) create mode 100644 _examples/expand_option/example.env create mode 100644 _examples/expand_option/main.go diff --git a/README.md b/README.md index 5b11058..e1462e5 100644 --- a/README.md +++ b/README.md @@ -67,16 +67,19 @@ To update cfgenv to the latest version, run: Fields in config structs can use the `env` tag to override cfgenv loading behaviour -| Tag | Purpose | -|---------------------|-------------------------------------------------------------------------------------------------------------------| -| `env:"MY"` | overrides the environment var name to read with `MY` | -| `env:"optional"` | denotes the environment var is optional | -| `env:"default=foo"` | denotes the default value if the environment var is missing | -| `env:"prefix=SUB"` | _(on a struct field)_ denotes all fields on the embedded struct will load from env var names prefixed with `SUB_` | -| `env:"prefix=SUB_"` | _(on a `map[string]string` field)_ denotes the map will read all env vars whose name starts with `SUB_` | +| Tag | Purpose | +|----------------------------------------|-------------------------------------------------------------------------------------------------------------------| +| `env:"MY"` | overrides the environment var name to read with `MY` | +| `env:"optional"` | denotes the environment var is optional | +| `env:"default=foo"` | denotes the default value if the environment var is missing | +| `env:"prefix=SUB"` | _(on a struct field)_ denotes all fields on the embedded struct will load from env var names prefixed with `SUB_` | +| `env:"prefix=SUB_"` | _(on a `map[string]string` field)_ denotes the map will read all env vars whose name starts with `SUB_` | +| `env:"delimiter=;"`
`env:"delim=;"` | _(on `slice` and `map` fields)_ denotes the character used to delimit items
_(the default is `,`)_ | +| `env:"separator=:"`
`env:"sep=:"` | _(on `map` fields)_ denotes the character used to separate key and value
_(the default is `:`)_ | + ## Options -When loading config from environment vars, several option interfaces can be passed to `cfgenv.Load` to alter the names of expected environment vars +When loading config from environment vars, several option interfaces can be passed to `cfgenv.Load()` function to alter the names of expected environment vars or provide support for extra field types.
@@ -227,6 +230,18 @@ DB.port=33601 DB.username=root DB.password=root ``` +
+
+
+ cfgenv.ExpandOption + +### `cfgenv.ExpandOption` +Providing an cfgenv.ExpandOption to the cfgenv.Load() function allows support for resolving substitute environment variables - e.g. `EXAMPLE=${FOO}-{$BAR}` + +Use the Expand() function - or implement your own ExpandOption + +Example - see [expand_option](https://github.com/go-andiamo/cfgenv/tree/main/_examples/expand_option) +

diff --git a/_examples/expand_option/example.env b/_examples/expand_option/example.env new file mode 100644 index 0000000..2077e3a --- /dev/null +++ b/_examples/expand_option/example.env @@ -0,0 +1,3 @@ +FOO=foo value +BAR=bar value +EXAMPLE=${FOO}-${BAR} diff --git a/_examples/expand_option/main.go b/_examples/expand_option/main.go new file mode 100644 index 0000000..411dcb8 --- /dev/null +++ b/_examples/expand_option/main.go @@ -0,0 +1,20 @@ +package main + +import ( + "fmt" + "github.com/go-andiamo/cfgenv" +) + +type Config struct { + Example string +} + +func main() { + cfg := &Config{} + err := cfgenv.Load(cfg, cfgenv.Expand()) + if err != nil { + panic(err) + } else { + fmt.Printf("%+v\n", cfg) + } +} diff --git a/loader.go b/loader.go index 7d07606..dfa8b10 100644 --- a/loader.go +++ b/loader.go @@ -58,6 +58,7 @@ func buildOpts(options ...any) (*opts, error) { countPfx := 0 countSep := 0 countNm := 0 + countExpand := 0 for _, o := range options { if o != nil { used := false @@ -76,6 +77,11 @@ func buildOpts(options ...any) (*opts, error) { result.naming = nm countNm++ } + if ex, ok := o.(ExpandOption); ok { + used = true + result.expand = ex + countExpand++ + } if cs, ok := o.(CustomSetterOption); ok { used = true result.customs = append(result.customs, cs) @@ -94,6 +100,9 @@ func buildOpts(options ...any) (*opts, error) { if countNm > 1 { return nil, errors.New("multiple naming options") } + if countExpand > 1 { + return nil, errors.New("multiple expand options") + } return result, nil } @@ -101,6 +110,7 @@ type opts struct { prefix PrefixOption separator SeparatorOption naming NamingOption + expand ExpandOption customs []CustomSetterOption } @@ -123,11 +133,16 @@ func loadStruct(v reflect.Value, prefix string, options *opts) error { if err = fi.customSetter.Set(fld, v.Field(f), raw); err != nil { return err } - } else if fi.prefixedMap { + } else if fi.isPrefixedMap { pfx := addPrefixes(prefix, fi.prefix, options.separator.GetSeparator()) - setPrefixMap(v.Field(f), pfx) - } else if fld.Type.Kind() == reflect.Struct { + setPrefixMap(v.Field(f), pfx, options) + } else if fi.isStruct { fv := v.Field(f) + if fi.pointer { + fvp := reflect.New(fv.Type().Elem()) + fv.Set(fvp) + fv = fvp.Elem() + } pfx := addPrefixes(prefix, fi.prefix, options.separator.GetSeparator()) if err = loadStruct(fv, pfx, options); err != nil { return err @@ -141,7 +156,10 @@ func loadStruct(v reflect.Value, prefix string, options *opts) error { } else if !ok && fi.pointer { continue } - if err = setValue(name, raw, fld, v.Field(f)); err != nil { + if options.expand != nil { + raw = options.expand.Expand(raw) + } + if err = setValue(name, raw, fld, fi, v.Field(f)); err != nil { return err } } @@ -152,72 +170,58 @@ func loadStruct(v reflect.Value, prefix string, options *opts) error { var durationType = reflect.TypeOf(time.Duration(0)) -func setValue(name string, raw string, fld reflect.StructField, fv reflect.Value) (err error) { +func setValue(name string, raw string, fld reflect.StructField, fi *fieldInfo, fv reflect.Value) (err error) { k := fv.Type().Kind() - isPtr := false - if k == reflect.Pointer { - isPtr = true + if fi.pointer { k = fv.Type().Elem().Kind() } switch k { case reflect.String: - if isPtr { - fv.Set(reflect.ValueOf(&raw)) - } else { - fv.Set(reflect.ValueOf(raw)) - } + setStringValue(raw, fv, fi.pointer) case reflect.Bool: - if b, bErr := strconv.ParseBool(raw); bErr == nil { - if isPtr { - fv.Set(reflect.ValueOf(&b)) - } else { - fv.Set(reflect.ValueOf(b)) - } - } else { - return fmt.Errorf("env var '%s' is not a bool", name) - } + err = setBoolValue(name, raw, fv, fi.pointer) case reflect.Int: - err = setIntValue[int](name, raw, fv, isPtr) + err = setIntValue[int](name, raw, fv, fi.pointer) case reflect.Int8: - err = setIntValue[int8](name, raw, fv, isPtr) + err = setIntValue[int8](name, raw, fv, fi.pointer) case reflect.Int16: - err = setIntValue[int16](name, raw, fv, isPtr) + err = setIntValue[int16](name, raw, fv, fi.pointer) case reflect.Int32: - err = setIntValue[int32](name, raw, fv, isPtr) + err = setIntValue[int32](name, raw, fv, fi.pointer) case reflect.Int64: if fv.Type() == durationType { - err = setIntValue[time.Duration](name, raw, fv, isPtr) + err = setIntValue[time.Duration](name, raw, fv, fi.pointer) } else { - err = setIntValue[int64](name, raw, fv, isPtr) + err = setIntValue[int64](name, raw, fv, fi.pointer) } case reflect.Uint: - err = setUintValue[uint](name, raw, fv, isPtr) + err = setUintValue[uint](name, raw, fv, fi.pointer) case reflect.Uint8: - err = setUintValue[uint8](name, raw, fv, isPtr) + err = setUintValue[uint8](name, raw, fv, fi.pointer) case reflect.Uint16: - err = setUintValue[uint16](name, raw, fv, isPtr) + err = setUintValue[uint16](name, raw, fv, fi.pointer) case reflect.Uint32: - err = setUintValue[uint32](name, raw, fv, isPtr) + err = setUintValue[uint32](name, raw, fv, fi.pointer) case reflect.Uint64: - err = setUintValue[uint64](name, raw, fv, isPtr) + err = setUintValue[uint64](name, raw, fv, fi.pointer) case reflect.Float32: - err = setFloatValue[float32](name, raw, fv, isPtr) + err = setFloatValue[float32](name, raw, fv, fi.pointer) case reflect.Float64: - err = setFloatValue[float64](name, raw, fv, isPtr) + err = setFloatValue[float64](name, raw, fv, fi.pointer) case reflect.Slice: - err = setSlice(name, raw, fld, fv) + err = setSlice(name, raw, fld, fi, fv) case reflect.Map: - err = setMap(name, raw, fld, fv) + err = setMap(name, raw, fld, fi, fv) } return } -func setSlice(name string, raw string, fld reflect.StructField, fv reflect.Value) error { +func setSlice(name string, raw string, fld reflect.StructField, fi *fieldInfo, fv reflect.Value) error { if raw != "" { - vs := strings.Split(raw, ",") + vs := strings.Split(raw, fi.delimiter) sl := reflect.MakeSlice(fv.Type(), len(vs), len(vs)) for i, v := range vs { - if err := setValue(name, v, fld, sl.Index(i)); err != nil { + if err := setValue(name, v, fld, fi, sl.Index(i)); err != nil { return err } } @@ -226,23 +230,23 @@ func setSlice(name string, raw string, fld reflect.StructField, fv reflect.Value return nil } -func setMap(name string, raw string, fld reflect.StructField, fv reflect.Value) error { +func setMap(name string, raw string, fld reflect.StructField, fi *fieldInfo, fv reflect.Value) error { if raw != "" { - vs := strings.Split(raw, ",") + vs := strings.Split(raw, fi.delimiter) m := reflect.MakeMap(fv.Type()) kt := fv.Type().Key() vt := fv.Type().Elem() for _, v := range vs { - kvp := strings.Split(v, ":") + kvp := strings.Split(v, fi.separator) if len(kvp) != 2 { return fmt.Errorf("env var '%s' contains invalid key/value pair - %s", name, v) } kv := reflect.New(kt).Elem() - if err := setValue(name, kvp[0], fld, kv); err != nil { + if err := setValue(name, kvp[0], fld, fi, kv); err != nil { return err } vv := reflect.New(vt).Elem() - if err := setValue(name, kvp[1], fld, vv); err != nil { + if err := setValue(name, kvp[1], fld, fi, vv); err != nil { return err } m.SetMapIndex(kv, vv) @@ -252,28 +256,39 @@ func setMap(name string, raw string, fld reflect.StructField, fv reflect.Value) return nil } -func setPrefixMap(fv reflect.Value, prefix string) { +func setPrefixMap(fv reflect.Value, prefix string, options *opts) { m := map[string]string{} for _, e := range os.Environ() { if strings.HasPrefix(e, prefix) { ev := strings.SplitN(e, "=", 2) - m[ev[0][len(prefix):]] = ev[1] + if options.expand != nil { + m[ev[0][len(prefix):]] = options.expand.Expand(ev[1]) + } else { + m[ev[0][len(prefix):]] = ev[1] + } } } fv.Set(reflect.ValueOf(m)) } -func setFloatValue[T float32 | float64](name string, raw string, fv reflect.Value, isPtr bool) error { - if f, err := strconv.ParseFloat(raw, getBitSize(fv, isPtr)); err == nil { +func setStringValue(raw string, fv reflect.Value, isPtr bool) { + if isPtr { + fv.Set(reflect.ValueOf(&raw)) + } else { + fv.Set(reflect.ValueOf(raw)) + } +} + +func setBoolValue(name string, raw string, fv reflect.Value, isPtr bool) error { + if b, bErr := strconv.ParseBool(raw); bErr == nil { if isPtr { - pv := T(f) - fv.Set(reflect.ValueOf(&pv)) + fv.Set(reflect.ValueOf(&b)) } else { - fv.Set(reflect.ValueOf(T(f))) + fv.Set(reflect.ValueOf(b)) } return nil } else { - return fmt.Errorf("env var '%s' is not a float", name) + return fmt.Errorf("env var '%s' is not a bool", name) } } @@ -305,6 +320,20 @@ func setUintValue[T uint | uint8 | uint16 | uint32 | uint64](name string, raw st } } +func setFloatValue[T float32 | float64](name string, raw string, fv reflect.Value, isPtr bool) error { + if f, err := strconv.ParseFloat(raw, getBitSize(fv, isPtr)); err == nil { + if isPtr { + pv := T(f) + fv.Set(reflect.ValueOf(&pv)) + } else { + fv.Set(reflect.ValueOf(T(f))) + } + return nil + } else { + return fmt.Errorf("env var '%s' is not a float", name) + } +} + func getBitSize(fv reflect.Value, isPtr bool) int { if isPtr { return fv.Type().Elem().Bits() @@ -323,49 +352,54 @@ func addPrefixes(currPfx, addPfx string, separator string) string { var tagSplitter = splitter.MustCreateSplitter(',', splitter.DoubleQuotes, splitter.SingleQuotes). AddDefaultOptions(splitter.Trim(" "), splitter.IgnoreEmpties) +var eqSplitter = splitter.MustCreateSplitter('=', splitter.DoubleQuotes, splitter.SingleQuotes). + AddDefaultOptions(splitter.Trim(" ")) func getFieldInfo(fld reflect.StructField, options *opts) (*fieldInfo, error) { - isPtr, custom, err := checkFieldType(fld, options) + result, err := checkFieldType(fld, options) if err != nil { return nil, err } - result := &fieldInfo{ - pointer: isPtr, - optional: isPtr, - customSetter: custom, - } if tag, ok := fld.Tag.Lookup("env"); ok { parts, err := tagSplitter.Split(tag) if err != nil { return nil, fmt.Errorf("invalid tag '%s' on field '%s'", tag, fld.Name) } for _, s := range parts { - if strings.Contains(s, "=") { - if pts := strings.Split(s, "="); len(pts) == 2 { - switch pts[0] { - case "default": - result.hasDefault = true - result.defaultValue = unquoted(pts[1]) - continue - case "prefix": - result.prefix = pts[1] - if fld.Type.Kind() == reflect.Map { - result.prefixedMap = fld.Type.Elem().Kind() == reflect.String && fld.Type.Key().Kind() == reflect.String - } - if !result.prefixedMap && fld.Type.Kind() != reflect.Struct { - return nil, fmt.Errorf("cannot use env tag 'prefix' on field '%s' (only for structs or map[string]string)", fld.Name) - } - continue + if pts, _ := eqSplitter.Split(s); len(pts) == 2 { + switch pts[0] { + case "default": + result.hasDefault = true + result.defaultValue = unquoted(pts[1]) + continue + case "prefix": + result.prefix = pts[1] + if fld.Type.Kind() == reflect.Map { + result.isPrefixedMap = fld.Type.Elem().Kind() == reflect.String && fld.Type.Key().Kind() == reflect.String + } + if !result.isPrefixedMap && !result.isStruct { + return nil, fmt.Errorf("cannot use env tag 'prefix' on field '%s' (only for structs or map[string]string)", fld.Name) } + continue + case "separator", "sep": + result.separator = unquoted(pts[1]) + continue + case "delimiter", "delim": + result.delimiter = unquoted(pts[1]) + continue } return nil, fmt.Errorf("invalid tag '%s' on field '%s'", s, fld.Name) - } else { + } else if len(pts) == 1 { switch s { case "optional": result.optional = true + case "default", "prefix", "separator", "sep", "delimiter", "delim": + return nil, fmt.Errorf("cannot use env tag '%s' without value on field '%s' (use quotes if necessary)", s, fld.Name) default: result.name = unquoted(s) } + } else { + return nil, fmt.Errorf("invalid tag '%s' on field '%s'", s, fld.Name) } } } @@ -380,24 +414,31 @@ func unquoted(s string) string { return s } -func checkFieldType(fld reflect.StructField, options *opts) (isPtr bool, custom CustomSetterOption, err error) { +func checkFieldType(fld reflect.StructField, options *opts) (*fieldInfo, error) { + isPtr := false k := fld.Type.Kind() if isPtr = k == reflect.Pointer; isPtr { k = fld.Type.Elem().Kind() } + result := &fieldInfo{ + pointer: isPtr, + optional: isPtr, + separator: ":", + delimiter: ",", + } for _, c := range options.customs { if ok := c.IsApplicable(fld); ok { - custom = c - break + result.customSetter = c + return result, nil } } if isNativeType(k) { - return + return result, nil } switch k { case reflect.Slice: if isPtr { - err = fmt.Errorf("field '%s' has unsupported type - %s", fld.Name, fld.Type.String()) + return nil, fmt.Errorf("field '%s' has unsupported type - %s", fld.Name, fld.Type.String()) } else { // check slice item type... it := fld.Type.Elem() @@ -405,12 +446,12 @@ func checkFieldType(fld reflect.StructField, options *opts) (isPtr bool, custom it = it.Elem() } if !isNativeType(it.Kind()) { - err = fmt.Errorf("field '%s' has unsupported slice item type", fld.Name) + return nil, fmt.Errorf("field '%s' has unsupported slice item type", fld.Name) } } case reflect.Map: if isPtr { - err = fmt.Errorf("field '%s' has unsupported type - %s", fld.Name, fld.Type.String()) + return nil, fmt.Errorf("field '%s' has unsupported type - %s", fld.Name, fld.Type.String()) } else { // check map item type... it := fld.Type.Elem() @@ -418,25 +459,21 @@ func checkFieldType(fld reflect.StructField, options *opts) (isPtr bool, custom it = it.Elem() } if !isNativeType(it.Kind()) { - err = fmt.Errorf("field '%s' has unsupported map item type", fld.Name) + return nil, fmt.Errorf("field '%s' has unsupported map item type", fld.Name) } else { // check map key type... it = fld.Type.Key() if !isNativeType(it.Kind()) { - err = fmt.Errorf("field '%s' has unsupported map key type", fld.Name) + return nil, fmt.Errorf("field '%s' has unsupported map key type", fld.Name) } } } case reflect.Struct: - if isPtr { - err = fmt.Errorf("field '%s' has unsupported embedded struct ptr", fld.Name) - } + result.isStruct = true default: - if custom == nil { - err = fmt.Errorf("field '%s' has unsupported type - %s", fld.Name, fld.Type.String()) - } + return nil, fmt.Errorf("field '%s' has unsupported type - %s", fld.Name, fld.Type.String()) } - return + return result, nil } func isNativeType(k reflect.Kind) bool { @@ -451,12 +488,15 @@ func isNativeType(k reflect.Kind) bool { } type fieldInfo struct { - name string - optional bool - pointer bool - hasDefault bool - defaultValue string - prefix string - prefixedMap bool - customSetter CustomSetterOption + name string + optional bool + pointer bool + hasDefault bool + defaultValue string + prefix string + isStruct bool + isPrefixedMap bool + customSetter CustomSetterOption + separator string + delimiter string } diff --git a/loader_test.go b/loader_test.go index 9733082..d19b246 100644 --- a/loader_test.go +++ b/loader_test.go @@ -93,6 +93,12 @@ func TestLoad(t *testing.T) { }{}, expectError: "missing env var 'TEST'", }, + { + cfg: &struct { + BadTag string `env:"default=foo="` + }{}, + expectError: "invalid tag 'default=foo=' on field 'BadTag'", + }, { cfg: &struct { Test string @@ -147,6 +153,18 @@ func TestLoad(t *testing.T) { }, expect: `{"Test":"foo"}`, }, + { + cfg: &struct { + Test string `env:"prefix"` + }{}, + expectError: "cannot use env tag 'prefix' without value on field 'Test' (use quotes if necessary)", + }, + { + cfg: &struct { + Test string `env:"default"` + }{}, + expectError: "cannot use env tag 'default' without value on field 'Test' (use quotes if necessary)", + }, { cfg: &struct { Inner struct { @@ -472,6 +490,15 @@ func TestLoad(t *testing.T) { }, expect: `{"Test":[1,2,3]}`, }, + { + cfg: &struct { + Test []int `env:"delim=;"` + }{}, + env: map[string]string{ + "TEST": "1;2;3", + }, + expect: `{"Test":[1,2,3]}`, + }, { cfg: &struct { Test []int `env:"optional,default='1,2,3'"` @@ -505,6 +532,15 @@ func TestLoad(t *testing.T) { }, expect: `{"Test":{"1":10,"2":20}}`, }, + { + cfg: &struct { + Test map[int]int `env:"delim=;,sep='='"` + }{}, + env: map[string]string{ + "TEST": "1=10;2=20", + }, + expect: `{"Test":{"1":10,"2":20}}`, + }, { cfg: &struct { Test map[string]int @@ -618,9 +654,22 @@ func TestLoad(t *testing.T) { }, { cfg: &struct { - Test *struct{} + Inner *struct { + Test string + } `env:"prefix=SUB"` + }{}, + expectError: "missing env var 'SUB_TEST'", + }, + { + cfg: &struct { + Inner *struct { + Test string + } `env:"prefix=SUB"` }{}, - expectError: "field 'Test' has unsupported embedded struct ptr", + env: map[string]string{ + "SUB_TEST": "foo", + }, + expect: `{"Inner":{"Test":"foo"}}`, }, { cfg: &struct { @@ -688,6 +737,48 @@ func TestLoad(t *testing.T) { }{}, expectError: "cannot use env tag 'prefix' on field 'Test' (only for structs or map[string]string)", }, + { + cfg: &struct { + Test string + }{}, + options: []any{Expand(), Expand()}, + expectError: "multiple expand options", + }, + { + cfg: &struct { + Test string + }{}, + env: map[string]string{ + "TEST": "${FOO}-${BAR}", + "FOO": "foo!", + "BAR": "bar!", + }, + options: []any{Expand()}, + expect: `{"Test":"foo!-bar!"}`, + }, + { + cfg: &struct { + Test map[string]string `env:"prefix=SUB_"` + }{}, + env: map[string]string{ + "SUB_TEST": "${FOO}-${BAR}", + "FOO": "foo!", + "BAR": "bar!", + }, + options: []any{Expand()}, + expect: `{"Test":{"TEST":"foo!-bar!"}}`, + }, + { + cfg: &struct { + Test string + }{}, + env: map[string]string{ + "TEST": "${FOO}-${BAR}-${BAZ}", + "FOO": "foo!", + }, + options: []any{Expand(map[string]string{"BAR": "bar!"})}, + expect: `{"Test":"foo!-bar!-"}`, + }, } for i, tc := range testCases { t.Run(fmt.Sprintf("[%d]", i+1), func(t *testing.T) { diff --git a/options.go b/options.go index ccfee3e..092ff92 100644 --- a/options.go +++ b/options.go @@ -1,6 +1,7 @@ package cfgenv import ( + "os" "reflect" "regexp" "strings" @@ -117,3 +118,42 @@ func (d *dateTimeSetterOption) Set(fld reflect.StructField, v reflect.Value, raw } var dtType = reflect.TypeOf(time.Time{}) + +// Expand creates a default ExpandOption (for use in Load / LoadAs) +// +// Any supplied lookup maps are checked first - if a given env var name, e.g. "${FOO}", is +// not found in lookups then the value is taken from env var +func Expand(lookups ...map[string]string) ExpandOption { + return &expandOpt{ + lookups: lookups, + } +} + +// ExpandOption is an option that can be passed to Load or LoadAs +// and provides support for expanding environment var values like... +// +// FOO=${BAR} +type ExpandOption interface { + // Expand expands the env var value s + Expand(s string) string +} + +type expandOpt struct { + lookups []map[string]string +} + +func (e *expandOpt) Expand(s string) string { + return os.Expand(s, e.expand) +} + +func (e *expandOpt) expand(s string) string { + for _, m := range e.lookups { + if v, ok := m[s]; ok { + return e.Expand(v) + } + } + if v, ok := os.LookupEnv(s); ok { + return e.Expand(v) + } + return "" +} diff --git a/options_test.go b/options_test.go index 46c7ecf..41bdc81 100644 --- a/options_test.go +++ b/options_test.go @@ -1,8 +1,10 @@ package cfgenv import ( + "fmt" "github.com/stretchr/testify/assert" "os" + "reflect" "testing" "time" ) @@ -24,3 +26,75 @@ func TestDateTimeSetterOption(t *testing.T) { assert.NoError(t, err) assert.Equal(t, "2024-01-01T00:00:00Z", cfg.Test.Format(time.RFC3339)) } + +func TestDefaultNamingOption(t *testing.T) { + type Config struct { + TestMe string + } + ct := reflect.TypeOf(Config{}) + fld := ct.Field(0) + testCases := []struct { + prefix string + separator string + overrideName string + expect string + }{ + { + expect: "TEST_ME", + }, + { + overrideName: "FOO", + expect: "FOO", + }, + { + prefix: "MY", + expect: "MYTEST_ME", + }, + { + prefix: "MY", + separator: "_", + expect: "MY_TEST_ME", + }, + { + separator: "_", + expect: "TEST_ME", + }, + { + prefix: "MY", + separator: "_", + overrideName: "FOO", + expect: "MY_FOO", + }, + { + prefix: "", + separator: "_", + overrideName: "FOO", + expect: "FOO", + }, + } + for i, tc := range testCases { + t.Run(fmt.Sprintf("[%d]", i+1), func(t *testing.T) { + n := defaultNamingOption.BuildName(tc.prefix, tc.separator, fld, tc.overrideName) + assert.Equal(t, tc.expect, n) + }) + } +} + +func TestExpand(t *testing.T) { + ex := Expand() + v := ex.Expand("${FOO}-${BAR}") + assert.Equal(t, "-", v) + _ = os.Setenv("FOO", "a") + _ = os.Setenv("BAR", "b") + v = ex.Expand("${FOO}-${BAR}") + assert.Equal(t, "a-b", v) + + _ = os.Setenv("FOO", "${BAZ}") + _ = os.Setenv("BAZ", "baz!") + v = ex.Expand("${FOO}-${BAR}") + assert.Equal(t, "baz!-b", v) + + ex = Expand(map[string]string{"FOO": "foo!"}, map[string]string{"BAR": "bar!"}) + v = ex.Expand("${FOO}-${BAR}") + assert.Equal(t, "foo!-bar!", v) +} diff --git a/writer.go b/writer.go index aa0a71d..8fd4159 100644 --- a/writer.go +++ b/writer.go @@ -95,19 +95,28 @@ func writeValue(w io.Writer, v reflect.Value, prefix string, actual bool, option name := options.naming.BuildName(prefix, options.separator.GetSeparator(), fld, fi.name) if !seen[name] { seen[name] = true - if fld.Type.Kind() == reflect.Struct { + if fi.isStruct { fv := v.Field(f) + if fi.pointer { + if fv.IsNil() && actual { + continue + } else if fv.IsNil() { + fv = reflect.New(fv.Type().Elem()).Elem() + } else { + fv = fv.Elem() + } + } pfx := addPrefixes(prefix, fi.prefix, options.separator.GetSeparator()) if err = write(w, fv, pfx, actual, options); err != nil { return err } } else if !actual { - if !fi.prefixedMap { + if !fi.isPrefixedMap { if err = writeExampleValue(w, name, v.Field(f), fi); err != nil { return err } } - } else if fi.prefixedMap { + } else if fi.isPrefixedMap { m := v.Field(f).Interface().(map[string]string) for k, v := range m { added[k] = v diff --git a/writer_test.go b/writer_test.go index 6727e36..8d08c9c 100644 --- a/writer_test.go +++ b/writer_test.go @@ -71,6 +71,9 @@ func TestExample(t *testing.T) { i := 1 b := true f := 1.1 + type inner struct { + Test string + } testCases := []struct { cfg any actual bool @@ -427,6 +430,31 @@ func TestExample(t *testing.T) { }, actual: true, expect: `TEST=foo +`, + }, + { + cfg: &struct { + Inner *inner `env:"prefix=SUB"` + }{}, + actual: true, + }, + { + cfg: &struct { + Inner *inner `env:"prefix=SUB"` + }{}, + expect: `SUB_TEST= +`, + }, + { + cfg: &struct { + Inner *inner `env:"prefix=SUB"` + }{ + Inner: &inner{ + Test: "foo", + }, + }, + actual: true, + expect: `SUB_TEST=foo `, }, }