From 7f2fd952eb2af0b019e8c89bc82cb4e8d2824374 Mon Sep 17 00:00:00 2001 From: Tronje Krop Date: Thu, 19 Sep 2024 14:02:39 +0200 Subject: [PATCH] feat: add default config handling (#6) Signed-off-by: Tronje Krop --- VERSION | 2 +- config/config.go | 113 +++-- config/config_test.go | 19 +- {fixtures => config/fixtures}/test.yaml | 3 + go.mod | 2 +- internal/reflect/walker.go | 169 +++++-- internal/reflect/walker_test.go | 617 ++++++++++++++++++------ log/log_test.go | 54 ++- 8 files changed, 708 insertions(+), 271 deletions(-) rename {fixtures => config/fixtures}/test.yaml (59%) diff --git a/VERSION b/VERSION index 4e379d2..bcab45a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.2 +0.0.3 diff --git a/config/config.go b/config/config.go index a910bee..a9330de 100644 --- a/config/config.go +++ b/config/config.go @@ -10,6 +10,7 @@ import ( "strings" "github.com/spf13/viper" + "github.com/tkrop/go-config/info" "github.com/tkrop/go-config/internal/reflect" "github.com/tkrop/go-config/log" @@ -25,8 +26,8 @@ type Config struct { Log *log.Config } -// ConfigReader common config reader based on viper. -type ConfigReader[C any] struct { +// Reader common config reader based on viper. +type Reader[C any] struct { *viper.Viper } @@ -42,90 +43,100 @@ func GetEnvName(prefix string, name string) string { return name } -// New creates a new config reader with the given prefix, name, and pointer to -// a config struct. The config struct is used to evaluate the default config -// values and names from the `default`-tags. +// New creates a new config reader with the given prefix, name, and config +// struct. The config struct is evaluate for the non-zero default config tags +// and values different from zero to initialize the default config. func New[C any]( prefix, name string, config *C, -) *ConfigReader[C] { - c := &ConfigReader[C]{ +) *Reader[C] { + r := &Reader[C]{ Viper: viper.New(), } - c.SetConfigName(GetEnvName(prefix, name)) - c.SetConfigType("yaml") - c.AddConfigPath(".") - c.SetEnvPrefix(prefix) - c.AutomaticEnv() - c.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) - c.AllowEmptyEnv(true) + r.SetConfigName(GetEnvName(prefix, name)) + r.SetConfigType("yaml") + r.AddConfigPath(".") + r.SetEnvPrefix(prefix) + r.AutomaticEnv() + r.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) + r.AllowEmptyEnv(true) + + r.SetSubDefaults("", config, true) // Determine parent directory of this code file. if _, filename, _, ok := runtime.Caller(1); ok { filepath := path.Join(path.Dir(filename), "../..") - c.AddConfigPath(filepath) + r.AddConfigPath(filepath) } - // Set default values from config type. - reflect.NewTagWalker("default", nil). - Walk(config, "", c.setConfigDefault) - - return c -} - -// setConfigDefault sets the default value for the given path using the default -// tag value. -func (c *ConfigReader[C]) setConfigDefault( - _ reflect.Value, path, tag string, -) { - c.SetDefault(path, tag) + return r } // SetDefaults is a convenience method to configure the reader with defaults // and standard values. It is also calling the provide function to customize // values and add more defaults. -func (c *ConfigReader[C]) SetDefaults( - setup func(*ConfigReader[C]), -) *ConfigReader[C] { - info := info.GetDefault() - c.SetDefault("info.path", info.Path) - c.SetDefault("info.version", info.Version) - c.SetDefault("info.revision", info.Revision) - c.SetDefault("info.build", info.Build) - c.SetDefault("info.commit", info.Commit) - c.SetDefault("info.dirty", info.Dirty) - c.SetDefault("info.go", info.Go) - c.SetDefault("info.platform", info.Platform) - c.SetDefault("info.compiler", info.Compiler) - +func (r *Reader[C]) SetDefaults( + setup func(*Reader[C]), +) *Reader[C] { if setup != nil { - setup(c) + setup(r) } - return c + return r +} + +// SetSubDefaults is a convenience method to update the default values of a +// sub-section configured in the reader by using the given config struct. The +// config struct is scanned for `default`-tags and values to set the defaults. +// Depending on the `zero` flag the default values are either include setting +// zero values or ignoring them. +func (r *Reader[C]) SetSubDefaults( + key string, config any, zero bool, +) *Reader[C] { + info := info.GetDefault() + r.SetDefault("info.path", info.Path) + r.SetDefault("info.version", info.Version) + r.SetDefault("info.revision", info.Revision) + r.SetDefault("info.build", info.Build) + r.SetDefault("info.commit", info.Commit) + r.SetDefault("info.dirty", info.Dirty) + r.SetDefault("info.go", info.Go) + r.SetDefault("info.platform", info.Platform) + r.SetDefault("info.compiler", info.Compiler) + + reflect.NewTagWalker("default", "mapstructure", zero). + Walk(key, config, r.SetDefault) + + return r +} + +// SetDefault is a convenience method to set the default value for the given +// key in the config reader and return the config reader. +func (r *Reader[C]) SetDefault(key string, value any) { + r.Viper.SetDefault(key, value) } // ReadConfig is a convenience method to read the environment specific config // file to extend the default config. The context is used to distinguish // different calls in case of a panic by failures while loading the config // file. -func (c *ConfigReader[C]) ReadConfig(context string) *ConfigReader[C] { - err := c.ReadInConfig() +func (r *Reader[C]) ReadConfig(context string) *Reader[C] { + err := r.ReadInConfig() if err != nil { log.WithError(err).Errorf("failed to load config [%s]", context) panic(fmt.Errorf("failed to load config [%s]: %w", context, err)) } - return c + return r } // GetConfig is a convenience method to return the config without loading the // environment specific config file. The context is used to distinguish // different calls in case of a panic created by failures while unmarschalling // the config. -func (c *ConfigReader[C]) GetConfig(context string) *C { +func (r *Reader[C]) GetConfig(context string) *C { config := new(C) - err := c.Unmarshal(config) + err := r.Unmarshal(config) if err != nil { log.WithError(err).Errorf("failed to unmarshal config [%s]", context) panic(fmt.Errorf("failed to unmarshal config [%s]: %w", context, err)) @@ -142,6 +153,6 @@ func (c *ConfigReader[C]) GetConfig(context string) *C { // file and returns the config. The context is used to distinguish different // calls in case of a panic created by failures while loading the config file // and umarshalling the config. -func (c *ConfigReader[C]) LoadConfig(context string) *C { - return c.ReadConfig(context).GetConfig(context) +func (r *Reader[C]) LoadConfig(context string) *C { + return r.ReadConfig(context).GetConfig(context) } diff --git a/config/config_test.go b/config/config_test.go index 781c3c5..ed63acc 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -15,8 +15,8 @@ func TestReadConfig(t *testing.T) { // Given reader := config.New("TC", "test", &config.Config{}). - SetDefaults(func(c *config.ConfigReader[config.Config]) { - c.AddConfigPath("../fixtures") + SetDefaults(func(c *config.Reader[config.Config]) { + c.AddConfigPath("fixtures") }) // When @@ -29,6 +29,7 @@ func TestReadConfig(t *testing.T) { func TestReadConfig_UnmarshalFailure(t *testing.T) { t.Parallel() + type Config struct { config.Config `mapstructure:",squash"` Content int @@ -37,8 +38,8 @@ func TestReadConfig_UnmarshalFailure(t *testing.T) { // Given defer func() { _ = recover() }() reader := config.New("TC", "test", &Config{}). - SetDefaults(func(c *config.ConfigReader[Config]) { - c.AddConfigPath("../fixtures") + SetDefaults(func(c *config.Reader[Config]) { + c.AddConfigPath("fixtures") }) // When @@ -53,8 +54,8 @@ func TestReadConfig_FileNotFound(t *testing.T) { defer func() { _ = recover() }() t.Setenv("TC_ENV", "other") reader := config.New("TC", "test", &config.Config{}). - SetDefaults(func(c *config.ConfigReader[config.Config]) { - c.AddConfigPath("../fixtures") + SetDefaults(func(c *config.Reader[config.Config]) { + c.AddConfigPath("fixtures") }) // When @@ -68,8 +69,8 @@ func TestReadConfig_OverridingEnv(t *testing.T) { // Given t.Setenv("TC_LOG_LEVEL", "trace") reader := config.New("TC", "test", &config.Config{}). - SetDefaults(func(c *config.ConfigReader[config.Config]) { - c.AddConfigPath("../fixtures") + SetDefaults(func(c *config.Reader[config.Config]) { + c.AddConfigPath("fixtures") }) // When @@ -85,7 +86,7 @@ func TestReadConfig_OverridingFunc(t *testing.T) { // Given reader := config.New("TC", "test", &config.Config{}). - SetDefaults(func(c *config.ConfigReader[config.Config]) { + SetDefaults(func(c *config.Reader[config.Config]) { c.SetDefault("log.level", "trace") }) diff --git a/fixtures/test.yaml b/config/fixtures/test.yaml similarity index 59% rename from fixtures/test.yaml rename to config/fixtures/test.yaml index 8d12615..0491a5f 100644 --- a/fixtures/test.yaml +++ b/config/fixtures/test.yaml @@ -2,5 +2,8 @@ log: level: info caller: false +info: + path: github.com/tkrop/go-config + content: | Hello, World! \ No newline at end of file diff --git a/go.mod b/go.mod index a78d509..f8e4c3d 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/tkrop/go-config go 1.23.1 require ( + github.com/golang/mock v1.6.0 github.com/sirupsen/logrus v1.9.3 github.com/spf13/viper v1.19.0 github.com/stretchr/testify v1.9.0 @@ -13,7 +14,6 @@ require ( require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect - github.com/golang/mock v1.6.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/magiconair/properties v1.8.7 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect diff --git a/internal/reflect/walker.go b/internal/reflect/walker.go index bf95633..e4b51c1 100644 --- a/internal/reflect/walker.go +++ b/internal/reflect/walker.go @@ -3,72 +3,159 @@ package reflect import ( "reflect" + "slices" + "strconv" "strings" ) -// Exported reflect types. -type Value = reflect.Value - // TagWalker provides a way to walk through a struct and apply a function to // each field that is settable. type TagWalker struct { - tag string - path func(path, name string) string + dtag, mtag string + zero bool } -// NewTagWalker creates a new TagWalker with the given tag name and given -// separator for constructing paths. -func NewTagWalker( - tag string, - path func(path, name string) string, -) *TagWalker { - if path == nil { - // Default path function. Concatenates the current path with the field - // name separated by a dot `.`. If the path is empty, the field name is - // used as base path. - path = func(path, name string) string { - if path != "" { - return path + "." + strings.ToLower(name) - } - return strings.ToLower(name) - } - } - return &TagWalker{tag: tag, path: path} +// NewTagWalker creates a new TagWalker with the given default tag name and +// given map tag name. The walker integrates the [go-defaults][go-defaults] and +// [mapstructure][mapstructure] packages to setup default values in the config +// reader. However, the implementation is not dependent on these packages and +// can be used without them or similar packages. +// +// [go-defaults]: +// [mapstructure]: +func NewTagWalker(dtag, mtag string, zero bool) *TagWalker { + return &TagWalker{dtag: dtag, mtag: mtag, zero: zero} } // Walk walks through the fields of the given value and calls the given // function with the path and tag of each field that has a tag. func (w *TagWalker) Walk( - value any, path string, - call func(value reflect.Value, path, tag string), + key string, value any, + call func(path string, value any), ) { - w.walkTags(reflect.ValueOf(value), path, call) + w.walk(strings.ToLower(key), reflect.ValueOf(value), call) } -func (w *TagWalker) walkTags( - value reflect.Value, path string, - call func(kind reflect.Value, path, tag string), +// walk is the internal walker function that is called recursively for each +// element of the given value. The function calls the given function for each +// value to apply the path and tag of the field to ensure that all paths can be +// provided via environment variables to the config reader. +func (w *TagWalker) walk( + key string, value reflect.Value, + call func(path string, value any), ) { - if value.Kind() != reflect.Struct { - switch value.Kind() { - case reflect.Ptr, reflect.Slice, reflect.Array: - v := reflect.New(value.Type().Elem()) - w.walkTags(v.Elem(), path, call) + switch value.Kind() { + case reflect.Ptr: + // TODO: Find test case for this code! + // if value.IsZero() { + // value = reflect.New(value.Type().Elem()) + // } + w.walk(key, value.Elem(), call) + case reflect.Slice, reflect.Array: + for index := 0; index < value.Len(); index++ { + nkey := w.key(key, strconv.Itoa(index)) + w.walk(nkey, value.Index(index), call) + } + case reflect.Map: + for _, fkey := range value.MapKeys() { + nkey := w.key(key, fkey.String()) + w.walk(nkey, value.MapIndex(fkey), call) + } + case reflect.Struct: + w.walkStruct(key, value, call) + default: + if value.IsValid() && (!value.IsZero() || w.zero) { + call(key, value.Interface()) } - return // ignore non-struct values. } +} +// walkStruct walks through the fields of the given struct value and calls the +// given function with the path and tag of each field that has a tag. On each +// field it also calls recursively the `walk` function depth-first. +func (w *TagWalker) walkStruct( + key string, value reflect.Value, + call func(path string, value any), +) { vtype := value.Type() num := value.NumField() for index := 0; index < num; index++ { field := vtype.Field(index) - fvalue := value.Field(index) - npath := w.path(path, field.Name) - w.walkTags(fvalue, npath, call) + if field.IsExported() { + w.walkField(w.field(key, field), + value.Field(index), field, call) + } + } +} - tag := field.Tag.Get(w.tag) - if tag != "" { - call(fvalue, npath, tag) +// walkField walks through the given field value and calls the given function +// with the path and tag of the field. If the field is a struct, the function +// calls the `walkStruct` function to walk through the struct fields. If the +// field is a pointer, slice, array, or map, the function calls the `walk` +// function to walk through the field elements. +func (w *TagWalker) walkField( + key string, value reflect.Value, + field reflect.StructField, + call func(path string, value any), +) { + switch value.Kind() { + case reflect.Struct: + w.walkStruct(key, value, call) + case reflect.Ptr: + if value.IsZero() { + value = reflect.New(value.Type().Elem()) + } + w.walkField(key, value.Elem(), field, call) + case reflect.Slice, reflect.Array, reflect.Map: + if value.Len() == 0 { + call(key, field.Tag.Get(w.dtag)) + } else { + w.walk(key, value, call) } + default: + if value.IsValid() && !value.IsZero() { + call(key, value.Interface()) + } else { + call(key, field.Tag.Get(w.dtag)) + } + } +} + +// field returns the field key for the given field and whether it is squashed. +// If the field has a tag, the tag is used as terminal field name. If the tag +// is empty, the field name is used as terminal field name. If the tag contains +// a `squash` option, the key is not extended with the field name. +func (w *TagWalker) field( + key string, field reflect.StructField, +) string { + mtag := field.Tag.Get(w.mtag) + if mtag == "" { + return w.key(key, field.Name) + } + + args := strings.Split(mtag, ",") + if isStruct(field) && slices.Contains(args[1:], "squash") { + return key + } else if args[0] != "" { + return w.key(key, args[0]) + } + return w.key(key, field.Name) +} + +// isStruct evaluates whether the given field is a struct or a pointer to a +// struct. +func isStruct(field reflect.StructField) bool { + return (field.Type.Kind() == reflect.Struct || + field.Type.Kind() == reflect.Ptr && + field.Type.Elem().Kind() == reflect.Struct) +} + +// key is the default key building function. It concatenates the current key +// with the field name separated by a dot `.`. If the key is empty, the field +// name is used as base key. +func (w *TagWalker) key(key, name string) string { + if key != "" { + return key + "." + strings.ToLower(name) } + return strings.ToLower(name) } diff --git a/internal/reflect/walker_test.go b/internal/reflect/walker_test.go index 352603f..b015d90 100644 --- a/internal/reflect/walker_test.go +++ b/internal/reflect/walker_test.go @@ -3,105 +3,189 @@ package reflect_test import ( "testing" - "github.com/stretchr/testify/assert" "github.com/tkrop/go-config/internal/reflect" + "github.com/tkrop/go-testing/mock" "github.com/tkrop/go-testing/test" ) +//go:generate mockgen -package=reflect_test -destination=mock_callback_test.go -source=walker_test.go Callback + +// Callback is a mock interface for testing. +type Callback interface { + Call(path string, value any) +} + +// Call calls the Call method of the given mocks. +func Call(path string, value any) mock.SetupFunc { + return func(mocks *mock.Mocks) any { + return mock.Get(mocks, NewMockCallback).EXPECT().Call(path, value). + DoAndReturn(mocks.Do(Callback.Call)) + } +} + // tagWalkerParam contains a value and the expected tags. type tagWalkerParam struct { - value any - path string - expectPaths []string + value any + key string + zero bool + expect mock.SetupFunc } // testTagWalkerParams contains test cases for TagWalker.Walk. var testTagWalkerParams = map[string]tagWalkerParam{ - // Ignore non-struct values. - "bool": { - value: true, - }, - "pbool": { - value: new(bool), + // Test build-in values. + "nil": { + value: nil, }, - "sbool": { - value: []bool{}, + "bool": { + value: true, + expect: Call("", true), }, "int": { - value: int(1), - }, - "pint": { - value: new(int), - }, - "sint": { - value: []int{}, + value: int(1), + expect: Call("", 1), }, "uint": { - value: uint(1), - }, - "puint": { - value: new(uint), - }, - "suint": { - value: []uint{}, + value: uint(1), + expect: Call("", uint(1)), }, "float": { - value: float64(1.0), + value: float64(1.0), + expect: Call("", float64(1.0)), }, "complex": { - value: complex128(1.0), + value: complex128(1.0), + expect: Call("", complex128(1.0)), }, - "pcomplex": { - value: new(complex128), + "string": { + value: string("test"), + expect: Call("", "test"), }, - "scomplex": { - value: []complex128{}, + "byte": { + value: byte('a'), + expect: Call("", byte('a')), }, - "pfloat": { - value: new(float64), + "rune": { + value: rune('a'), + expect: Call("", rune('a')), }, - "sfloat": { - value: []float64{}, + "any": { + value: any(1), + expect: Call("", any(1)), }, - "string": { - value: string(""), + + // Test build-in pointer values. + "ptr-bool": { + value: new(bool), }, - "pstring": { - value: new(string), + "ptr-int": { + value: new(int), }, - "sstring": { - value: []string{}, + "ptr-uint": { + value: new(uint), }, - "byte": { - value: byte(0), + "ptr-float": { + value: new(float64), }, - "pbyte": { - value: new(byte), + "ptr-complex": { + value: new(complex128), }, - "sbyte": { - value: []byte{}, + "ptr-string": { + value: new(string), }, - "rune": { - value: rune(0), + "ptr-byte": { + value: new(byte), }, - "prune": { + "ptr-rune": { value: new(rune), }, - "srune": { - value: []rune{}, - }, - "any": { - value: any(0), - }, - "pany": { + "ptr-any": { value: new(any), }, - "sany": { - value: []any{}, + "ptr-slice": { + value: new([]any), + }, + + // Test build-in slice values. + "slice-bool": { + value: []bool{true, false}, + zero: true, + expect: mock.Chain( + Call("0", true), + Call("1", false), + ), + }, + "slice-int": { + value: []int{1, 0}, + zero: true, + expect: mock.Chain( + Call("0", 1), + Call("1", 0), + ), + }, + "slice-uint": { + value: []uint{1, 0}, + zero: true, + expect: mock.Chain( + Call("0", uint(1)), + Call("1", uint(0)), + ), + }, + "slice-float": { + value: []float64{1.0, 0.0}, + zero: true, + expect: mock.Chain( + Call("0", 1.0), + Call("1", 0.0), + ), + }, + "slice-complex": { + value: []complex128{1.0, 0.0}, + zero: true, + expect: mock.Chain( + Call("0", complex128(1.0)), + Call("1", complex128(0.0)), + ), + }, + "slice-string": { + zero: true, + value: []string{"test", ""}, + expect: mock.Chain( + Call("0", "test"), + Call("1", ""), + ), + }, + "slice-byte": { + value: []byte{'a', 'b'}, + expect: mock.Chain( + Call("0", byte('a')), + Call("1", byte('b')), + ), + }, + "slice-rune": { + value: []rune{'a', 'b'}, + expect: mock.Chain( + Call("0", rune('a')), + Call("1", rune('b')), + ), + }, + "slice-any": { + value: []any{0, "test"}, + expect: mock.Chain( + Call("0", 0), + Call("1", "test"), + ), }, - // Test struct fields. - "struct-ints": { + // Test struct with field tags. + "struct-bool-tags": { + value: struct { + hidden bool + Visible bool `tag:"visible"` + }{}, + expect: Call("visible", "visible"), + }, + "struct-ints-tags": { value: struct { I int `tag:"int"` PI *int `tag:"*int"` @@ -112,10 +196,16 @@ var testTagWalkerParams = map[string]tagWalkerParam{ I32 int32 `tag:"int32"` I64 int64 `tag:"int64"` }{}, - expectPaths: []string{ - "i", "pi", "si", "psi", - "i8", "i16", "i32", "i64", - }, + expect: mock.Chain( + Call("i", "int"), + Call("pi", "*int"), + Call("si", "[]int"), + Call("psi", "*[]int"), + Call("i8", "int8"), + Call("i16", "int16"), + Call("i32", "int32"), + Call("i64", "int64"), + ), }, "struct-uints": { value: struct { @@ -128,10 +218,16 @@ var testTagWalkerParams = map[string]tagWalkerParam{ UI32 uint32 `tag:"uint32"` UI64 uint64 `tag:"uint64"` }{}, - expectPaths: []string{ - "ui", "pui", "sui", "psui", - "ui8", "ui16", "ui32", "ui64", - }, + expect: mock.Chain( + Call("ui", "uint"), + Call("pui", "*uint"), + Call("sui", "[]uint"), + Call("psui", "*[]uint"), + Call("ui8", "uint8"), + Call("ui16", "uint16"), + Call("ui32", "uint32"), + Call("ui64", "uint64"), + ), }, "struct-floats": { value: struct { @@ -144,10 +240,16 @@ var testTagWalkerParams = map[string]tagWalkerParam{ PSF32 *[]float32 `tag:"*[]float32"` PSF64 *[]float64 `tag:"*[]float64"` }{}, - expectPaths: []string{ - "f32", "f64", "pf32", "pf64", - "sf32", "sf64", "psf32", "psf64", - }, + expect: mock.Chain( + Call("f32", "float32"), + Call("f64", "float64"), + Call("pf32", "*float32"), + Call("pf64", "*float64"), + Call("sf32", "[]float32"), + Call("sf64", "[]float64"), + Call("psf32", "*[]float32"), + Call("psf64", "*[]float64"), + ), }, "struct-complex": { value: struct { @@ -160,10 +262,16 @@ var testTagWalkerParams = map[string]tagWalkerParam{ PSF32 *[]complex64 `tag:"*[]complex64"` PSF64 *[]complex128 `tag:"*[]complex128"` }{}, - expectPaths: []string{ - "f32", "f64", "pf32", "pf64", - "sf32", "sf64", "psf32", "psf64", - }, + expect: mock.Chain( + Call("f32", "complex64"), + Call("f64", "complex128"), + Call("pf32", "*complex64"), + Call("pf64", "*complex128"), + Call("sf32", "[]complex64"), + Call("sf64", "[]complex128"), + Call("psf32", "*[]complex64"), + Call("psf64", "*[]complex128"), + ), }, "struct-strings": { value: struct { @@ -178,119 +286,336 @@ var testTagWalkerParams = map[string]tagWalkerParam{ SR []rune `tag:"[]int32"` PSR *[]rune `tag:"*[]int32"` }{}, - expectPaths: []string{ - "s", "ps", - "b", "pb", "sb", "psb", - "r", "pr", "sr", "psr", + expect: mock.Chain( + Call("s", "string"), + Call("ps", "*string"), + Call("b", "uint8"), + Call("pb", "*uint8"), + Call("sb", "[]uint8"), + Call("psb", "*[]uint8"), + Call("r", "int32"), + Call("pr", "*int32"), + Call("sr", "[]int32"), + Call("psr", "*[]int32"), + ), + }, + + // Test structs with field values. + "struct-all-values": { + value: struct { + Bool bool `map:"bool" default:"false"` + Int int `map:"int" default:"-2"` + Uint uint `map:"uint" default:"2"` + Float float64 `map:"float" default:"3.0"` + String string `map:"string" default:"STRING"` + Byte byte `map:"byte" default:"A"` + Rune rune `map:"rune" default:"B"` + Any any `map:"any" default:"ANY"` + }{ + Bool: true, + Int: int(-1), + Uint: uint(1), + Float: float64(2.0), + String: "string", + Byte: byte('a'), + Rune: rune('b'), + Any: map[string]any{"key": "value"}, }, + expect: mock.Chain( + Call("bool", true), + Call("int", -1), + Call("uint", uint(1)), + Call("float", float64(2.0)), + Call("string", "string"), + Call("byte", byte('a')), + Call("rune", rune('b')), + Call("any", map[string]any{"key": "value"}), + ), }, // Test struct with nested structs. - "struct": { - value: struct { - A any `tag:"interface {}"` - }{}, - expectPaths: []string{"a"}, + "struct-empty": { + value: struct{}{}, }, - "struct-map": { + "struct-any": { value: struct { - M map[string]any `tag:"map[string]interface {}"` + A any `tag:"any"` }{}, - expectPaths: []string{"m"}, + expect: Call("a", "any"), }, "struct-struct": { value: struct { S struct { - A any `tag:"interface {}"` - } `tag:"struct { A interface {} \"tag:\\\"interface {}\\\"\" }"` + A any `tag:"any"` + } `tag:"struct{any}"` }{}, - expectPaths: []string{"s.a", "s"}, + expect: mock.Chain( + Call("s.a", "any"), + ), }, "struct-ptr-struct": { value: struct { S *struct { - A any `tag:"interface {}"` - } `tag:"*struct { A interface {} \"tag:\\\"interface {}\\\"\" }"` + A any `tag:"any"` + } `tag:"*struct{any}"` }{}, - expectPaths: []string{"s.a", "s"}, + expect: mock.Chain( + Call("s.a", "any"), + ), }, - "struct-slice-struct": { + + // Test pointer struct with nested structs. + "ptr-struct-empty": { + value: &struct{}{}, + }, + "ptr-struct-any": { + value: &struct { + A any `tag:"any"` + }{}, + expect: Call("a", "any"), + }, + "ptr-struct-struct": { + value: &struct { + S struct { + A any `tag:"any"` + } `tag:"struct{any}"` + }{}, + expect: mock.Chain( + Call("s.a", "any"), + ), + }, + "ptr-struct-ptr-struct": { + value: &struct { + S *struct { + A any `tag:"any"` + } `tag:"*struct{any}"` + }{}, + expect: mock.Chain( + Call("s.a", "any"), + ), + }, + + // Test struct with nested slices and tags. + "struct-slice-tag": { + value: struct { + S []any `tag:"[]any"` + }{}, + expect: Call("s", "[]any"), + }, + "struct-slice-struct-tag": { value: struct { S []struct { - A any `tag:"interface {}"` - } `tag:"[]struct { A interface {} \"tag:\\\"interface {}\\\"\" }"` + A any `tag:"any"` + } `tag:"[]struct{any}"` }{}, - expectPaths: []string{"s.a", "s"}, + expect: Call("s", "[]struct{any}"), }, - "struct-slice-ptr-struct": { + "struct-slice-ptr-struct-tag": { value: struct { S []*struct { - A any `tag:"interface {}"` - } `tag:"[]*struct { A interface {} \"tag:\\\"interface {}\\\"\" }"` + A any `tag:"any"` + } `tag:"[]*struct{any}"` }{}, - expectPaths: []string{"s.a", "s"}, + expect: Call("s", "[]*struct{any}"), }, - "struct-ptr-slice-ptr-struct": { + "struct-ptr-slice-ptr-struct-tag": { value: struct { S *[]*struct { - A any `tag:"interface {}"` - } `tag:"*[]*struct { A interface {} \"tag:\\\"interface {}\\\"\" }"` + A any `tag:"any"` + } `tag:"*[]*struct{any}"` }{}, - expectPaths: []string{"s.a", "s"}, + expect: Call("s", "*[]*struct{any}"), }, - // Test pointer struct with nested structs. - "ptr-struct": { + // Test struct with nested slices and values. + "struct-slice-value": { + value: struct { + S []any `tag:"[]any"` + }{S: []any{1, 2}}, + expect: mock.Chain( + Call("s.0", 1), + Call("s.1", 2), + ), + }, + "struct-slice-struct-value": { + value: struct { + S []struct { + A any `tag:"any"` + } `tag:"[]struct{any}"` + }{S: []struct { + A any `tag:"any"` + }{{A: 1}, {A: 2}}}, + expect: mock.Chain( + Call("s.0.a", 1), + Call("s.1.a", 2), + ), + }, + "struct-slice-ptr-struct-value": { + value: struct { + S []*struct { + A any `tag:"any"` + } `tag:"[]*struct{any}"` + }{S: []*struct { + A any `tag:"any"` + }{{A: 1}, {A: 2}}}, + expect: mock.Chain( + Call("s.0.a", 1), + Call("s.1.a", 2), + ), + }, + "struct-ptr-slice-ptr-struct-value": { + value: struct { + S *[]*struct { + A any `tag:"any"` + } `tag:"*[]*struct{any}"` + }{S: &[]*struct { + A any `tag:"any"` + }{{A: 1}, {A: 2}}}, + expect: mock.Chain( + Call("s.0.a", 1), + Call("s.1.a", 2), + ), + }, + + // Test struct with nested maps. + "struct-map-tag": { + value: struct { + M map[string]any `tag:"map[string]any"` + }{}, + expect: Call("m", "map[string]any"), + }, + "struct-ptr-map-tag": { + value: struct { + M *map[string]any `tag:"*map[string]any"` + }{}, + expect: Call("m", "*map[string]any"), + }, + "struct-map-struct-tag": { value: &struct { - A any `tag:"interface {}"` + M map[string]struct { + A any `tag:"any"` + } `tag:"map[string]struct{any}"` }{}, - expectPaths: []string{"a"}, + expect: Call("m", "map[string]struct{any}"), }, - "ptr-struct-map": { + "struct-ptr-map-struct-tag": { value: &struct { - M map[string]any `tag:"map[string]interface {}"` + M map[string]struct { + A any `tag:"any"` + } `tag:"*map[string]struct{any}"` }{}, - expectPaths: []string{"m"}, + expect: Call("m", "*map[string]struct{any}"), }, - "ptr-struct-struct": { + "struct-map-ptr-struct-tag": { value: &struct { - S struct { - A any `tag:"interface {}"` - } `tag:"struct { A interface {} \"tag:\\\"interface {}\\\"\" }"` + M map[string]*struct { + A any `tag:"any"` + } `tag:"map[string]*struct{any}"` }{}, - expectPaths: []string{"s.a", "s"}, + expect: Call("m", "map[string]*struct{any}"), }, - "ptr-struct-ptr-struct": { + + // Test struct with nested maps. + "struct-map-value": { + value: struct { + M map[string]any `tag:"map[string]any"` + }{M: map[string]any{"key": "value"}}, + expect: Call("m.key", "value"), + }, + "struct-ptr-map-value": { + value: struct { + M *map[string]any `tag:"*map[string]any"` + }{M: &map[string]any{"key": "value"}}, + expect: Call("m.key", "value"), + }, + "struct-map-struct-value": { + value: struct { + M map[string]struct { + A any `tag:"any"` + } `tag:"map[string]struct{any}"` + }{M: map[string]struct { + A any `tag:"any"` + }{"key-0": {A: 1}, "key-1": {A: 2}}}, + expect: mock.Setup( + Call("m.key-0.a", 1), + Call("m.key-1.a", 2), + ), + }, + "struct-ptr-map-struct-value": { + value: struct { + M *map[string]struct { + A any `tag:"any"` + } `tag:"*map[string]struct{any}"` + }{M: &map[string]struct { + A any `tag:"any"` + }{"key-0": {A: 1}, "key-1": {A: 2}}}, + expect: mock.Setup( + Call("m.key-0.a", 1), + Call("m.key-1.a", 2), + ), + }, + "struct-ptr-map-ptr-struct-value": { + value: struct { + M *map[string]*struct { + A any `tag:"any"` + } `tag:"map[string]*struct{any}"` + }{M: &map[string]*struct { + A any `tag:"any"` + }{"key-0": {A: 1}, "key-1": {A: 2}}}, + expect: mock.Setup( + Call("m.key-0.a", 1), + Call("m.key-1.a", 2), + ), + }, + + // Test map structure tags. + "map-name": { value: &struct { - S *struct { - A any `tag:"interface {}"` - } `tag:"*struct { A interface {} \"tag:\\\"interface {}\\\"\" }"` + M any `map:"X" tag:"any"` }{}, - expectPaths: []string{"s.a", "s"}, + expect: Call("x", "any"), }, - "ptr-struct-slice-struct": { + "map-squash": { value: &struct { - S []struct { - A any `tag:"interface {}"` - } `tag:"[]struct { A interface {} \"tag:\\\"interface {}\\\"\" }"` + S struct { + A *any `map:"X" tag:"*any"` + } `map:",squash" tag:"struct{*any}"` }{}, - expectPaths: []string{"s.a", "s"}, + expect: mock.Chain( + Call("x", "*any"), + ), }, - "ptr-struct-slice-ptr-struct": { + "map-empty": { value: &struct { - S []*struct { - A any `tag:"interface {}"` - } `tag:"[]*struct { A interface {} \"tag:\\\"interface {}\\\"\" }"` + S struct { + A *any `map:",omitempty" tag:"*any"` + } `map:",squash" tag:"struct{*any}"` }{}, - expectPaths: []string{"s.a", "s"}, + expect: mock.Chain( + Call("a", "*any"), + ), }, - "ptr-struct-ptr-slice-ptr-struct": { + "map-remain": { value: &struct { - S *[]*struct { - A any `tag:"interface {}"` - } `tag:"*[]*struct { A interface {} \"tag:\\\"interface {}\\\"\" }"` + S struct { + A *any `map:",omitempty" tag:"*any"` + R map[string]any `map:",remain" tag:"map[string]any"` + } `map:",squash" tag:"struct{*any}"` + }{}, + expect: mock.Chain( + Call("a", "*any"), + Call("r", "map[string]any"), + ), + }, + "map-comma": { + value: &struct { + S struct { + A *any `map:"," tag:"*any"` + } `map:",squash" tag:"struct{*any}"` }{}, - expectPaths: []string{"s.a", "s"}, + expect: mock.Chain( + Call("a", "*any"), + ), }, } @@ -299,17 +624,13 @@ func TestTagWalker_Walk(t *testing.T) { test.Map(t, testTagWalkerParams). Run(func(t test.Test, param tagWalkerParam) { // Given - var paths []string - walker := reflect.NewTagWalker("tag", nil) + mocks := mock.NewMocks(t).Expect(param.expect) + walker := reflect.NewTagWalker("tag", "map", param.zero) // When - walker.Walk(param.value, param.path, - func(value reflect.Value, path, tag string) { - paths = append(paths, path) - assert.Equal(t, tag, value.Type().String()) - }) + walker.Walk(param.key, param.value, + mock.Get(mocks, NewMockCallback).Call) // Then - assert.Equal(t, param.expectPaths, paths) }) } diff --git a/log/log_test.go b/log/log_test.go index 1aa1f92..b190caf 100644 --- a/log/log_test.go +++ b/log/log_test.go @@ -10,16 +10,15 @@ import ( "github.com/tkrop/go-testing/test" ) -type setupLoggingParams struct { - logger *log.Logger +type setupParams struct { config *log.Config expectTimeFormat string expectLogLevel string expectLogCaller bool } -var testSetupLoggingParams = map[string]setupLoggingParams{ - "read default log config to std logger": { +var testSetupParams = map[string]setupParams{ + "read default log config no logger": { config: &log.Config{}, expectLogLevel: log.DefaultLogLevel, expectTimeFormat: log.DefaultLogTimeFormat, @@ -27,7 +26,6 @@ var testSetupLoggingParams = map[string]setupLoggingParams{ }, "read default log config": { - logger: log.New(), config: &log.Config{}, expectLogLevel: log.DefaultLogLevel, expectTimeFormat: log.DefaultLogTimeFormat, @@ -35,25 +33,33 @@ var testSetupLoggingParams = map[string]setupLoggingParams{ }, "change log level debug": { - logger: log.New(), config: &log.Config{ Level: "debug", }, expectLogLevel: "debug", expectTimeFormat: log.DefaultLogTimeFormat, + expectLogCaller: log.DefaultLogCaller, + }, + + "invalid log level debug": { + config: &log.Config{ + Level: "detail", + }, + expectLogLevel: "info", + expectTimeFormat: log.DefaultLogTimeFormat, + expectLogCaller: log.DefaultLogCaller, }, "change time format date": { - logger: log.New(), config: &log.Config{ TimeFormat: "2024-12-31", }, expectLogLevel: log.DefaultLogLevel, expectTimeFormat: "2024-12-31", + expectLogCaller: log.DefaultLogCaller, }, "change caller to true": { - logger: log.New(), config: &log.Config{ Caller: true, }, @@ -63,26 +69,34 @@ var testSetupLoggingParams = map[string]setupLoggingParams{ }, } -func TestSetupLogging(t *testing.T) { - test.Map(t, testSetupLoggingParams). - Run(func(t test.Test, param setupLoggingParams) { +func TestSetup(t *testing.T) { + test.Map(t, testSetupParams). + Run(func(t test.Test, param setupParams) { // Given - config.New("TEST", "test", &config.Config{}). - SetDefaults(func(c *config.ConfigReader[config.Config]) { - c.AddConfigPath("../fixtures") - }).LoadConfig(t.Name()).Log.Setup(param.logger) + logger := log.New() + config := config.New("TEST", "test", &config.Config{}). + SetSubDefaults("log", param.config, false). + GetConfig(t.Name()) // When - param.config.Setup(param.logger) + config.Log.Setup(logger) // Then - logger := param.logger - if logger == nil { - logger = log.StandardLogger() - } assert.Equal(t, param.expectTimeFormat, logger.Formatter.(*log.TextFormatter).TimestampFormat) assert.Equal(t, param.expectLogLevel, logger.GetLevel().String()) assert.Equal(t, param.expectLogCaller, logger.ReportCaller) }) } + +func TestNilLogger(t *testing.T) { + // Given + config := config.New("TEST", "test", &config.Config{}). + GetConfig(t.Name()) + + // When + config.Log.Setup(nil) + + // Then + assert.True(t, true) +}