diff --git a/README.md b/README.md index cb20f918..c15be1c3 100644 --- a/README.md +++ b/README.md @@ -32,11 +32,12 @@ configuration source(s) (implementation) it actually wants to use. Something lik // Create the Config. config := konf.New() - // Load configuration from embed file system and environment variables. - if err := config.Load( - fs.New(config, "config/config.json"), - env.New(env.WithPrefix("server")), - ); err != nil { + // Load configuration from embed file system. + if err := config.Load(fs.New(config, "config/config.json")); err != nil { + // Handle error here. + } + // Load configuration from environment variables. + if err := config.Load(env.New(env.WithPrefix("server"))); err != nil { // Handle error here. } diff --git a/config.go b/config.go index f34f8681..48800a83 100644 --- a/config.go +++ b/config.go @@ -4,14 +4,11 @@ package konf import ( - "context" - "errors" "fmt" "log/slog" "slices" "strings" "sync" - "time" "github.com/go-viper/mapstructure/v2" @@ -52,7 +49,6 @@ func New(opts ...Option) *Config { if option.logger == nil { option.logger = slog.Default() } - option.logger = option.logger.WithGroup("konf") if option.delimiter == "" { option.delimiter = "." } @@ -70,173 +66,54 @@ func New(opts ...Option) *Config { return (*Config)(option) } -// Load loads configuration from the given loaders. +// Load loads configuration from the given loader. // Each loader takes precedence over the loaders before it. // // This method can be called multiple times but it is not concurrency-safe. -// It panics if any loader is nil. -func (c *Config) Load(loaders ...Loader) error { - for i, loader := range loaders { - if loader == nil { - panic(fmt.Sprintf("cannot load config from nil loader at loaders[%d]", i)) - } - - values, err := loader.Load() - if err != nil { - return fmt.Errorf("load configuration: %w", err) - } - maps.Merge(c.values, values) - - provider := &provider{ - loader: loader, - values: make(map[string]any), - } - // Merged to empty map to convert to lower case. - maps.Merge(provider.values, values) - c.providers = append(c.providers, provider) - } - - return nil -} - -// Watch watches and updates configuration when it changes. -// It blocks until ctx is done, or the service returns an error. -// WARNING: All loaders passed in Load after calling Watch do not get watched. -// -// It only can be called once. Call after first has no effects. -// It panics if ctx is nil. -func (c *Config) Watch(ctx context.Context) error { //nolint:cyclop,funlen,gocognit - if ctx == nil { - panic("cannot watch change with nil context") +// It panics if loader is nil. +func (c *Config) Load(loader Loader) error { + if loader == nil { + panic("cannot load config from nil loader") } - if hasWatcher := slices.ContainsFunc(c.providers, func(provider *provider) bool { - _, ok := provider.loader.(Watcher) - - return ok - }); !hasWatcher { - return nil + values, err := loader.Load() + if err != nil { + return fmt.Errorf("load configuration: %w", err) } + maps.Merge(c.values, values) - watched := true - c.watchOnce.Do(func() { - watched = false - }) - if watched { - c.logger.Warn("Config has been watched, call Watch again has no effects.") - - return nil + provider := &provider{ + loader: loader, + values: make(map[string]any), } + // Merged to empty map to convert to lower case. + maps.Merge(provider.values, values) + c.providers = append(c.providers, provider) - onChangesChannel := make(chan []func(*Config)) - defer close(onChangesChannel) - ctx, cancel := context.WithCancel(ctx) - defer cancel() - - var waitGroup sync.WaitGroup - waitGroup.Add(1) - go func() { - defer waitGroup.Done() - - for { - select { - case onChanges := <-onChangesChannel: - values := make(map[string]any) - for _, w := range c.providers { - maps.Merge(values, w.values) - } - c.values = values - c.logger.DebugContext(ctx, "Configuration has been updated with change.") - - if len(onChanges) > 0 { - func() { - ctx, cancel = context.WithTimeout(ctx, time.Minute) - defer cancel() - - done := make(chan struct{}) - go func() { - defer close(done) - - for _, onChange := range onChanges { - onChange(c) - } - }() - - select { - case <-done: - c.logger.DebugContext(ctx, "Configuration has been applied to onChanges.") - case <-ctx.Done(): - if errors.Is(ctx.Err(), context.DeadlineExceeded) { - c.logger.WarnContext(ctx, "Configuration has not been fully applied to onChanges due to timeout."+ - " Please check if the onChanges is blocking or takes too long to complete.") - } - } - }() - } - - case <-ctx.Done(): - return - } - } - }() - - errChan := make(chan error, len(c.providers)) - for _, provider := range c.providers { - provider := provider - - if watcher, ok := provider.loader.(Watcher); ok { - waitGroup.Add(1) - go func() { - defer waitGroup.Done() - - onChange := func(values map[string]any) { - // Merged to empty map to convert to lower case. - newValues := make(map[string]any) - maps.Merge(newValues, values) - - oldValues := provider.values - provider.values = newValues - - // Find the onChanges should be triggered. - onChanges := func() []func(*Config) { - c.onChangesMutex.RLock() - defer c.onChangesMutex.RUnlock() - - var callbacks []func(*Config) - for path, onChanges := range c.onChanges { - keys := strings.Split(path, c.delimiter) - if sub(oldValues, keys) != nil || sub(newValues, keys) != nil { - callbacks = append(callbacks, onChanges...) - } - } - - return callbacks - } - onChangesChannel <- onChanges() - - c.logger.Info( - "Configuration has been changed.", - "loader", watcher, - ) - } + return nil +} - c.logger.DebugContext(ctx, "Watching configuration change.", "loader", watcher) - if err := watcher.Watch(ctx, onChange); err != nil { - errChan <- fmt.Errorf("watch configuration change: %w", err) - cancel() - } - }() - } +// Unmarshal reads configuration under the given path from the Config +// and decodes it into the given object pointed to by target. +// The path is case-insensitive. +func (c *Config) Unmarshal(path string, target any) error { + decoder, err := mapstructure.NewDecoder( + &mapstructure.DecoderConfig{ + Result: target, + WeaklyTypedInput: true, + DecodeHook: c.decodeHook, + TagName: c.tagName, + }, + ) + if err != nil { + return fmt.Errorf("new decoder: %w", err) } - waitGroup.Wait() - close(errChan) - var err error - for e := range errChan { - err = errors.Join(e) + if err := decoder.Decode(sub(c.values, strings.Split(strings.ToLower(path), c.delimiter))); err != nil { + return fmt.Errorf("decode: %w", err) } - return err + return nil } func sub(values map[string]any, keys []string) any { @@ -261,57 +138,6 @@ func sub(values map[string]any, keys []string) any { return next } -// OnChange registers a callback function that is executed -// when the value of any given path in the Config changes. -// It requires Config.Watch has been called first. -// The paths are case-insensitive. -// -// The onChange function must be non-blocking and usually completes instantly. -// If it requires a long time to complete, it should be executed in a separate goroutine. -// -// This method is concurrency-safe. -// It panics if onChange is nil. -func (c *Config) OnChange(onChange func(*Config), paths ...string) { - if onChange == nil { - panic("cannot register nil onChange") - } - - c.onChangesMutex.Lock() - defer c.onChangesMutex.Unlock() - - if len(paths) == 0 { - paths = []string{""} - } - - for _, path := range paths { - path = strings.ToLower(path) - c.onChanges[path] = append(c.onChanges[path], onChange) - } -} - -// Unmarshal reads configuration under the given path from the Config -// and decodes it into the given object pointed to by target. -// The path is case-insensitive. -func (c *Config) Unmarshal(path string, target any) error { - decoder, err := mapstructure.NewDecoder( - &mapstructure.DecoderConfig{ - Result: target, - WeaklyTypedInput: true, - DecodeHook: c.decodeHook, - TagName: c.tagName, - }, - ) - if err != nil { - return fmt.Errorf("new decoder: %w", err) - } - - if err := decoder.Decode(sub(c.values, strings.Split(strings.ToLower(path), c.delimiter))); err != nil { - return fmt.Errorf("decode: %w", err) - } - - return nil -} - // Explain provides information about how Config resolve each value // from loaders for the given path. // The path is case-insensitive. diff --git a/config_test.go b/config_test.go index 2d2ebb50..be4cc29a 100644 --- a/config_test.go +++ b/config_test.go @@ -4,11 +4,7 @@ package konf_test import ( - "context" - "errors" - "sync/atomic" "testing" - "time" "github.com/nil-go/konf" "github.com/nil-go/konf/internal/assert" @@ -109,8 +105,9 @@ func TestConfig_Unmarshal(t *testing.T) { t.Parallel() config := konf.New(testcase.opts...) - err := config.Load(testcase.loaders...) - assert.NoError(t, err) + for _, loader := range testcase.loaders { + assert.NoError(t, config.Load(loader)) + } testcase.assert(config) }) } @@ -126,98 +123,12 @@ func (m mapLoader) String() string { return "map" } -func TestConfig_Watch(t *testing.T) { - t.Parallel() - - config := konf.New() - watcher := mapWatcher(make(chan map[string]any)) - err := config.Load(watcher) - assert.NoError(t, err) - - var value string - assert.NoError(t, config.Unmarshal("config", &value)) - assert.Equal(t, "string", value) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - go func() { - assert.NoError(t, config.Watch(ctx)) - }() - - var newValue atomic.Value - config.OnChange(func(config *konf.Config) { - var value string - assert.NoError(t, config.Unmarshal("config", &value)) - newValue.Store(value) - }, "config") - watcher.change(map[string]any{"Config": "changed"}) - assert.Equal(t, "changed", newValue.Load()) -} - -type mapWatcher chan map[string]any - -func (m mapWatcher) Load() (map[string]any, error) { - return map[string]any{"Config": "string"}, nil -} - -func (m mapWatcher) Watch(ctx context.Context, fn func(map[string]any)) error { - for { - select { - case values := <-m: - fn(values) - case <-ctx.Done(): - return nil - } - } -} - -func (m mapWatcher) change(values map[string]any) { - m <- values - - time.Sleep(time.Second) // Wait for change gets propagated. -} - -func TestConfig_Watch_error(t *testing.T) { - t.Parallel() - - config := konf.New() - err := config.Load(errorWatcher{}) - assert.NoError(t, err) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - assert.EqualError(t, config.Watch(ctx), "watch configuration change: watch error") -} - -type errorWatcher struct{} - -func (errorWatcher) Load() (map[string]any, error) { - return make(map[string]any), nil -} - -func (errorWatcher) Watch(context.Context, func(map[string]any)) error { - return errors.New("watch error") -} - -func TestConfig_error(t *testing.T) { - t.Parallel() - - config := konf.New() - err := config.Load(errorLoader{}) - assert.EqualError(t, err, "load configuration: load error") -} - -type errorLoader struct{} - -func (errorLoader) Load() (map[string]any, error) { - return nil, errors.New("load error") -} - func TestConfig_Explain(t *testing.T) { t.Setenv("CONFIG_NEST", "env") config := konf.New() - err := config.Load(env.New(), mapLoader{"owner": "map", "config": map[string]any{"nest": "map"}}) + err := config.Load(env.New()) + assert.NoError(t, err) + err = config.Load(mapLoader{"owner": "map", "config": map[string]any{"nest": "map"}}) assert.NoError(t, err) assert.Equal(t, "non-exist has no configuration.\n\n", config.Explain("non-exist")) diff --git a/default_test.go b/default_test.go index f0437cc1..6603c33c 100644 --- a/default_test.go +++ b/default_test.go @@ -48,7 +48,7 @@ func TestGet_error(t *testing.T) { assert.True(t, !konf.Get[bool]("config")) expected := "WARN Could not read config, return empty value instead." + - " konf.error=\"decode: cannot parse '' as bool: strconv.ParseBool: parsing \\\"string\\\": invalid syntax\"" + - " konf.path=config konf.type=bool\n" + " error=\"decode: cannot parse '' as bool: strconv.ParseBool: parsing \\\"string\\\": invalid syntax\"" + + " path=config type=bool\n" assert.Equal(t, expected, buf.String()) } diff --git a/example_test.go b/example_test.go index ca76e1d5..aa36a7cd 100644 --- a/example_test.go +++ b/example_test.go @@ -43,11 +43,11 @@ var testdata embed.FS func ExampleSetDefault() { config := konf.New() - err := config.Load( - kfs.New(testdata, "testdata/config.json"), - env.New(env.WithPrefix("server")), - ) - if err != nil { + if err := config.Load(kfs.New(testdata, "testdata/config.json")); err != nil { + // Handle error here. + panic(err) + } + if err := config.Load(env.New(env.WithPrefix("server"))); err != nil { // Handle error here. panic(err) } diff --git a/option.go b/option.go index 56d471de..b6366662 100644 --- a/option.go +++ b/option.go @@ -40,12 +40,14 @@ func WithDecodeHook(decodeHook mapstructure.DecodeHookFunc) Option { } } -// WithLogger provides the slog.Logger for Config. +// WithLogHandler provides the slog.Handler for logs from watch. // -// By default, it uses slog.Default(). -func WithLogger(logger *slog.Logger) Option { +// By default, it uses handler from slog.Default(). +func WithLogHandler(handler slog.Handler) Option { return func(options *options) { - options.logger = logger + if handler != nil { + options.logger = slog.New(handler) + } } } diff --git a/provider/file/file.go b/provider/file/file.go index a2262343..cf5b8c80 100644 --- a/provider/file/file.go +++ b/provider/file/file.go @@ -8,9 +8,6 @@ // // The unmarshal function must be able to unmarshal the file content into a map[string]any. // For example, with the default json.Unmarshal, the file is parsed as JSON. -// -// By default, it returns error while loading if the file is not found. -// IgnoreFileNotExit can override the behavior to return an empty map[string]any. package file import ( @@ -24,10 +21,9 @@ import ( // // To create a new File, call [New]. type File struct { - logger *slog.Logger - path string - unmarshal func([]byte, any) error - ignoreNotExist bool + logger *slog.Logger + unmarshal func([]byte, any) error + path string } // New creates a File with the given path and Option(s). @@ -47,7 +43,6 @@ func New(path string, opts ...Option) File { if option.logger == nil { option.logger = slog.Default() } - option.logger = option.logger.WithGroup("konf.file") if option.unmarshal == nil { option.unmarshal = json.Unmarshal } @@ -58,12 +53,6 @@ func New(path string, opts ...Option) File { func (f File) Load() (map[string]any, error) { bytes, err := os.ReadFile(f.path) if err != nil { - if f.ignoreNotExist && os.IsNotExist(err) { - f.logger.Warn("Config file does not exist.", "file", f.path) - - return make(map[string]any), nil - } - return nil, fmt.Errorf("read file: %w", err) } diff --git a/provider/file/file_test.go b/provider/file/file_test.go index 029867bb..45220f89 100644 --- a/provider/file/file_test.go +++ b/provider/file/file_test.go @@ -36,12 +36,6 @@ func TestFile_Load(t *testing.T) { path: "not_found.json", err: "read file: open not_found.json: ", }, - { - description: "file (ignore not exist)", - path: "not_found.json", - opts: []file.Option{file.IgnoreFileNotExit()}, - expected: map[string]any{}, - }, { description: "unmarshal error", path: "testdata/config.json", diff --git a/provider/file/option.go b/provider/file/option.go index f8ea851d..ad88fe06 100644 --- a/provider/file/option.go +++ b/provider/file/option.go @@ -15,19 +15,14 @@ func WithUnmarshal(unmarshal func([]byte, any) error) Option { } } -// IgnoreFileNotExit ignores the error and return an empty map instead if the configuration file is not found. -func IgnoreFileNotExit() Option { - return func(options *options) { - options.ignoreNotExist = true - } -} - -// WithLogger provides the slog.Logger for File loader. +// WithLogHandler provides the slog.Handler for logs from watch. // -// By default, it uses slog.Default(). -func WithLogger(logger *slog.Logger) Option { +// By default, it uses handler from slog.Default(). +func WithLogHandler(handler slog.Handler) Option { return func(options *options) { - options.logger = logger + if handler != nil { + options.logger = slog.New(handler) + } } } diff --git a/provider/fs/fs.go b/provider/fs/fs.go index 31cb9f6b..47d021a4 100644 --- a/provider/fs/fs.go +++ b/provider/fs/fs.go @@ -8,28 +8,21 @@ // // The unmarshal function must be able to unmarshal the file content into a map[string]any. // For example, with the default json.Unmarshal, the file is parsed as JSON. -// -// By default, it returns error while loading if the file is not found. -// IgnoreFileNotExit can override the behavior to return an empty map[string]any. package fs import ( "encoding/json" "fmt" "io/fs" - "log/slog" - "os" ) // FS is a Provider that loads configuration from file system. // // To create a new FS, call [New]. type FS struct { - logger *slog.Logger - unmarshal func([]byte, any) error - fs fs.FS - path string - ignoreNotExist bool + unmarshal func([]byte, any) error + fs fs.FS + path string } // New creates a FS with the given fs.FS, path and Option(s). @@ -50,10 +43,6 @@ func New(fs fs.FS, path string, opts ...Option) FS { //nolint:varnamelen for _, opt := range opts { opt(option) } - if option.logger == nil { - option.logger = slog.Default() - } - option.logger = option.logger.WithGroup("konf.fs") if option.unmarshal == nil { option.unmarshal = json.Unmarshal } @@ -64,12 +53,6 @@ func New(fs fs.FS, path string, opts ...Option) FS { //nolint:varnamelen func (f FS) Load() (map[string]any, error) { bytes, err := fs.ReadFile(f.fs, f.path) if err != nil { - if f.ignoreNotExist && os.IsNotExist(err) { - f.logger.Warn("Config file does not exist.", "file", f.path) - - return make(map[string]any), nil - } - return nil, fmt.Errorf("read file: %w", err) } diff --git a/provider/fs/fs_test.go b/provider/fs/fs_test.go index 441a2753..ab5a175b 100644 --- a/provider/fs/fs_test.go +++ b/provider/fs/fs_test.go @@ -46,15 +46,6 @@ func TestFile_Load(t *testing.T) { path: "not_found.json", err: "read file: open not_found.json: file does not exist", }, - { - description: "fs file (ignore not exist)", - fs: fstest.MapFS{}, - path: "not_found.json", - opts: []kfs.Option{ - kfs.IgnoreFileNotExit(), - }, - expected: map[string]any{}, - }, { description: "unmarshal error", fs: fstest.MapFS{ diff --git a/provider/fs/option.go b/provider/fs/option.go index e2d4a815..2c66ca96 100644 --- a/provider/fs/option.go +++ b/provider/fs/option.go @@ -3,8 +3,6 @@ package fs -import "log/slog" - // WithUnmarshal provides the function used to parses the configuration file. // The unmarshal function must be able to unmarshal the file content into a map[string]any. // @@ -15,22 +13,6 @@ func WithUnmarshal(unmarshal func([]byte, any) error) Option { } } -// IgnoreFileNotExit ignores the error and return an empty map instead if the configuration file is not found. -func IgnoreFileNotExit() Option { - return func(options *options) { - options.ignoreNotExist = true - } -} - -// WithLogger provides the slog.Logger for FS loader. -// -// By default, it uses slog.Default(). -func WithLogger(logger *slog.Logger) Option { - return func(options *options) { - options.logger = logger - } -} - type ( // Option configures the a FS with specific options. Option func(file *options) diff --git a/watch.go b/watch.go new file mode 100644 index 00000000..44ed96da --- /dev/null +++ b/watch.go @@ -0,0 +1,184 @@ +// Copyright (c) 2024 The konf authors +// Use of this source code is governed by a MIT license found in the LICENSE file. + +package konf + +import ( + "context" + "errors" + "fmt" + "slices" + "strings" + "sync" + "time" + + "github.com/nil-go/konf/internal/maps" +) + +// Watch watches and updates configuration when it changes. +// It blocks until ctx is done, or the service returns an error. +// WARNING: All loaders passed in Load after calling Watch do not get watched. +// +// It only can be called once. Call after first has no effects. +// It panics if ctx is nil. +func (c *Config) Watch(ctx context.Context) error { //nolint:cyclop,funlen,gocognit + if ctx == nil { + panic("cannot watch change with nil context") + } + + if hasWatcher := slices.ContainsFunc(c.providers, func(provider *provider) bool { + _, ok := provider.loader.(Watcher) + + return ok + }); !hasWatcher { + return nil + } + + watched := true + c.watchOnce.Do(func() { + watched = false + }) + if watched { + c.logger.Warn("Config has been watched, call Watch again has no effects.") + + return nil + } + + onChangesChannel := make(chan []func(*Config)) + defer close(onChangesChannel) + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + var waitGroup sync.WaitGroup + waitGroup.Add(1) + go func() { + defer waitGroup.Done() + + for { + select { + case onChanges := <-onChangesChannel: + values := make(map[string]any) + for _, w := range c.providers { + maps.Merge(values, w.values) + } + c.values = values + c.logger.DebugContext(ctx, "Configuration has been updated with change.") + + if len(onChanges) > 0 { + func() { + ctx, cancel = context.WithTimeout(ctx, time.Minute) + defer cancel() + + done := make(chan struct{}) + go func() { + defer close(done) + + for _, onChange := range onChanges { + onChange(c) + } + }() + + select { + case <-done: + c.logger.DebugContext(ctx, "Configuration has been applied to onChanges.") + case <-ctx.Done(): + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + c.logger.WarnContext(ctx, "Configuration has not been fully applied to onChanges due to timeout."+ + " Please check if the onChanges is blocking or takes too long to complete.") + } + } + }() + } + + case <-ctx.Done(): + return + } + } + }() + + errChan := make(chan error, len(c.providers)) + for _, provider := range c.providers { + provider := provider + + if watcher, ok := provider.loader.(Watcher); ok { + waitGroup.Add(1) + go func() { + defer waitGroup.Done() + + onChange := func(values map[string]any) { + // Merged to empty map to convert to lower case. + newValues := make(map[string]any) + maps.Merge(newValues, values) + + oldValues := provider.values + provider.values = newValues + + // Find the onChanges should be triggered. + onChanges := func() []func(*Config) { + c.onChangesMutex.RLock() + defer c.onChangesMutex.RUnlock() + + var callbacks []func(*Config) + for path, onChanges := range c.onChanges { + keys := strings.Split(path, c.delimiter) + if sub(oldValues, keys) != nil || sub(newValues, keys) != nil { + callbacks = append(callbacks, onChanges...) + } + } + + return callbacks + } + onChangesChannel <- onChanges() + + c.logger.Info( + "Configuration has been changed.", + "loader", watcher, + ) + } + + c.logger.DebugContext(ctx, "Watching configuration change.", "loader", watcher) + if err := watcher.Watch(ctx, onChange); err != nil { + errChan <- fmt.Errorf("watch configuration change: %w", err) + cancel() + } + }() + } + } + waitGroup.Wait() + close(errChan) + + var err error + for e := range errChan { + err = errors.Join(e) + } + + return err +} + +// OnChange registers a callback function that is executed +// when the value of any given path in the Config changes. +// It requires Config.Watch has been called first. +// The paths are case-insensitive. +// +// The onChange function must be non-blocking and usually completes instantly. +// If it requires a long time to complete, it should be executed in a separate goroutine. +// +// This method is concurrency-safe. +// It panics if onChange is nil. +func (c *Config) OnChange(onChange func(*Config), paths ...string) { + if onChange == nil { + panic("cannot register nil onChange") + } + + c.onChangesMutex.Lock() + defer c.onChangesMutex.Unlock() + + if len(paths) == 0 { + paths = []string{""} + } + + for _, path := range paths { + path = strings.ToLower(path) + c.onChanges[path] = append(c.onChanges[path], onChange) + } +} diff --git a/watch_test.go b/watch_test.go new file mode 100644 index 00000000..5013d543 --- /dev/null +++ b/watch_test.go @@ -0,0 +1,103 @@ +// Copyright (c) 2024 The konf authors +// Use of this source code is governed by a MIT license found in the LICENSE file. + +package konf_test + +import ( + "context" + "errors" + "sync/atomic" + "testing" + "time" + + "github.com/nil-go/konf" + "github.com/nil-go/konf/internal/assert" +) + +func TestConfig_Watch(t *testing.T) { + t.Parallel() + + config := konf.New() + watcher := mapWatcher(make(chan map[string]any)) + err := config.Load(watcher) + assert.NoError(t, err) + + var value string + assert.NoError(t, config.Unmarshal("config", &value)) + assert.Equal(t, "string", value) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go func() { + assert.NoError(t, config.Watch(ctx)) + }() + + var newValue atomic.Value + config.OnChange(func(config *konf.Config) { + var value string + assert.NoError(t, config.Unmarshal("config", &value)) + newValue.Store(value) + }, "config") + watcher.change(map[string]any{"Config": "changed"}) + assert.Equal(t, "changed", newValue.Load()) +} + +type mapWatcher chan map[string]any + +func (m mapWatcher) Load() (map[string]any, error) { + return map[string]any{"Config": "string"}, nil +} + +func (m mapWatcher) Watch(ctx context.Context, fn func(map[string]any)) error { + for { + select { + case values := <-m: + fn(values) + case <-ctx.Done(): + return nil + } + } +} + +func (m mapWatcher) change(values map[string]any) { + m <- values + + time.Sleep(time.Second) // Wait for change gets propagated. +} + +func TestConfig_Watch_error(t *testing.T) { + t.Parallel() + + config := konf.New() + err := config.Load(errorWatcher{}) + assert.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + assert.EqualError(t, config.Watch(ctx), "watch configuration change: watch error") +} + +type errorWatcher struct{} + +func (errorWatcher) Load() (map[string]any, error) { + return make(map[string]any), nil +} + +func (errorWatcher) Watch(context.Context, func(map[string]any)) error { + return errors.New("watch error") +} + +func TestConfig_error(t *testing.T) { + t.Parallel() + + config := konf.New() + err := config.Load(errorLoader{}) + assert.EqualError(t, err, "load configuration: load error") +} + +type errorLoader struct{} + +func (errorLoader) Load() (map[string]any, error) { + return nil, errors.New("load error") +}