v0.1.2 minor refactor, helpers back to private, utility update
This commit is contained in:
@ -82,7 +82,7 @@ func (b *Builder) Build() (*Config, error) {
|
|||||||
b.cfg.configFilePath = b.file
|
b.cfg.configFilePath = b.file
|
||||||
|
|
||||||
// 2. Load configuration
|
// 2. Load configuration
|
||||||
loadErr := b.cfg.LoadWithOptions(b.file, b.args, b.opts)
|
loadErr := b.cfg.loadWithOptions(b.file, b.args, b.opts)
|
||||||
if loadErr != nil && !errors.Is(loadErr, ErrConfigNotFound) {
|
if loadErr != nil && !errors.Is(loadErr, ErrConfigNotFound) {
|
||||||
// Return on fatal load errors. ErrConfigNotFound is not fatal.
|
// Return on fatal load errors. ErrConfigNotFound is not fatal.
|
||||||
return nil, wrapError(ErrFileAccess, loadErr)
|
return nil, wrapError(ErrFileAccess, loadErr)
|
||||||
|
|||||||
@ -45,9 +45,6 @@ const (
|
|||||||
EventPrecedenceChanged = "precedence"
|
EventPrecedenceChanged = "precedence"
|
||||||
)
|
)
|
||||||
|
|
||||||
// debounceSettleMultiplier ensures sufficient time for debounce to complete
|
|
||||||
const debounceSettleMultiplier = 3 // Wait 3x debounce period for value stabilization
|
|
||||||
|
|
||||||
// Channel and resource limits
|
// Channel and resource limits
|
||||||
const (
|
const (
|
||||||
DefaultMaxWatchers = 100
|
DefaultMaxWatchers = 100
|
||||||
|
|||||||
@ -42,13 +42,13 @@ func (c *Config) unmarshal(source Source, target any, basePath ...string) error
|
|||||||
if source == "" {
|
if source == "" {
|
||||||
// Use current merged state
|
// Use current merged state
|
||||||
for path, item := range c.items {
|
for path, item := range c.items {
|
||||||
SetNestedValue(nestedMap, path, item.currentValue)
|
setNestedValue(nestedMap, path, item.currentValue)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Use specific source
|
// Use specific source
|
||||||
for path, item := range c.items {
|
for path, item := range c.items {
|
||||||
if val, exists := item.values[source]; exists {
|
if val, exists := item.values[source]; exists {
|
||||||
SetNestedValue(nestedMap, path, val)
|
setNestedValue(nestedMap, path, val)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -85,7 +85,7 @@ Live Reload:
|
|||||||
- **Security**: `WithSecurityOptions()`
|
- **Security**: `WithSecurityOptions()`
|
||||||
|
|
||||||
### Source Loading (`loader.go`)
|
### Source Loading (`loader.go`)
|
||||||
- **Multi-Source Loading**: `Load()`, `LoadWithOptions()`
|
- **Multi-Source Loading**: `loadWithOptions()`
|
||||||
- **Individual Sources**: `LoadFile()`, `LoadEnv()`, `LoadCLI()`
|
- **Individual Sources**: `LoadFile()`, `LoadEnv()`, `LoadCLI()`
|
||||||
- **Persistence**: `Save()`, `SaveSource()`, `atomicWriteFile()`
|
- **Persistence**: `Save()`, `SaveSource()`, `atomicWriteFile()`
|
||||||
- **Environment Mapping**: `DiscoverEnv()`, `ExportEnv()`, `defaultEnvTransform()`
|
- **Environment Mapping**: `DiscoverEnv()`, `ExportEnv()`, `defaultEnvTransform()`
|
||||||
@ -105,11 +105,11 @@ Live Reload:
|
|||||||
- **Change Detection**: `checkAndReload()`, `performReload()`, `notifyWatchers()`
|
- **Change Detection**: `checkAndReload()`, `performReload()`, `notifyWatchers()`
|
||||||
- **Resource Management**: Max watcher limits, graceful shutdown
|
- **Resource Management**: Max watcher limits, graceful shutdown
|
||||||
|
|
||||||
### Convenience API (`convenience.go`)
|
### Convenience API (`utility.go`)
|
||||||
- **Quick Setup**: `Quick()`, `QuickCustom()`, `MustQuick()`, `QuickTyped()`
|
- **Quick Setup**: `Quick()`, `QuickCustom()`, `MustQuick()`, `QuickTyped()`
|
||||||
- **Flag Integration**: `GenerateFlags()`, `BindFlags()`
|
- **Flag Integration**: `GenerateFlags()`, `BindFlags()`
|
||||||
- **Type-Safe Access**: `GetTyped()`, `GetTypedWithDefault()`, `ScanTyped()`
|
- **Type-Safe Access**: `GetTyped()`, `GetTypedWithDefault()`, `ScanTyped()`
|
||||||
- **Utilities**: `Validate()`, `Debug()`, `Dump()`, `Clone()`
|
- **Utilities**: `Validate()`, `Debug()`, `Dump()`, `Clone()`, `ScanMap()`
|
||||||
|
|
||||||
### File Discovery (`discovery.go`)
|
### File Discovery (`discovery.go`)
|
||||||
- **Options**: `FileDiscoveryOptions` struct with search strategies
|
- **Options**: `FileDiscoveryOptions` struct with search strategies
|
||||||
|
|||||||
@ -103,6 +103,7 @@ These are the **preferred methods** for accessing configuration data
|
|||||||
|
|
||||||
* `AsStruct() (any, error)`: After using `Builder.WithTarget()`, this method returns the populated, type-safe target struct - This is the primary way to access config after initialization or live reload
|
* `AsStruct() (any, error)`: After using `Builder.WithTarget()`, this method returns the populated, type-safe target struct - This is the primary way to access config after initialization or live reload
|
||||||
* `Scan(basePath string, target any)`: Populates a struct with values from a specific config path (e.g., `cfg.Scan("server", &serverConf)`)
|
* `Scan(basePath string, target any)`: Populates a struct with values from a specific config path (e.g., `cfg.Scan("server", &serverConf)`)
|
||||||
|
* `ScanMap(configMap map[string]any, target any, tagName ...string)`: Decodes a map[string]any directly into a target struct, useful for plugins or custom config sources
|
||||||
* `GetTyped[T](c *Config, path string) (T, error)`: Retrieves a single value and decodes it to type `T`, handling type conversion automatically
|
* `GetTyped[T](c *Config, path string) (T, error)`: Retrieves a single value and decodes it to type `T`, handling type conversion automatically
|
||||||
* `ScanTyped[T](c *Config, basePath ...string) (*T, error)`: A generic wrapper around `Scan` that allocates, populates, and returns a pointer to a struct of type `T`
|
* `ScanTyped[T](c *Config, basePath ...string) (*T, error)`: A generic wrapper around `Scan` that allocates, populates, and returns a pointer to a struct of type `T`
|
||||||
|
|
||||||
@ -156,9 +157,3 @@ These methods are for dynamic key-value access that require type assertion and s
|
|||||||
* `Save(path string)`: Atomically saves the current merged configuration state to a file
|
* `Save(path string)`: Atomically saves the current merged configuration state to a file
|
||||||
* `Clone() *Config`: Creates a deep copy of the configuration state
|
* `Clone() *Config`: Creates a deep copy of the configuration state
|
||||||
* `Debug() string`: Returns a formatted string of all values for debugging
|
* `Debug() string`: Returns a formatted string of all values for debugging
|
||||||
|
|
||||||
### Utility Functions
|
|
||||||
|
|
||||||
* `FlattenMap(nested map[string]any, prefix string) map[string]any`: Converts a nested map to a flat map with dot-notation keys
|
|
||||||
* `SetNestedValue(nested map[string]any, path string, value any)`: Sets a value in a nested map using a dot-notation path
|
|
||||||
* `IsValidKeySegment(s string) bool`: Checks if a string is a valid path segment
|
|
||||||
18
helper.go
18
helper.go
@ -3,20 +3,20 @@ package config
|
|||||||
|
|
||||||
import "strings"
|
import "strings"
|
||||||
|
|
||||||
// FlattenMap converts a nested map[string]any to a flat map[string]any with dot-notation paths
|
// flattenMap converts a nested map[string]any to a flat map[string]any with dot-notation paths
|
||||||
func FlattenMap(nested map[string]any, prefix string) map[string]any {
|
func flattenMap(nestedMap map[string]any, prefix string) map[string]any {
|
||||||
flat := make(map[string]any)
|
flat := make(map[string]any)
|
||||||
|
|
||||||
for key, value := range nested {
|
for key, value := range nestedMap {
|
||||||
newPath := key
|
newPath := key
|
||||||
if prefix != "" {
|
if prefix != "" {
|
||||||
newPath = prefix + "." + key
|
newPath = prefix + "." + key
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the value is a map that can be further flattened
|
// Check if the value is a map that can be further flattened
|
||||||
if nestedMap, isMap := value.(map[string]any); isMap {
|
if nested, isMap := value.(map[string]any); isMap {
|
||||||
// Recursively flatten the nested map
|
// Recursively flatten the nested map
|
||||||
flattenedSubMap := FlattenMap(nestedMap, newPath)
|
flattenedSubMap := flattenMap(nested, newPath)
|
||||||
// Merge the flattened sub-map into the main flat map
|
// Merge the flattened sub-map into the main flat map
|
||||||
for subPath, subValue := range flattenedSubMap {
|
for subPath, subValue := range flattenedSubMap {
|
||||||
flat[subPath] = subValue
|
flat[subPath] = subValue
|
||||||
@ -30,10 +30,10 @@ func FlattenMap(nested map[string]any, prefix string) map[string]any {
|
|||||||
return flat
|
return flat
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetNestedValue sets a value in a nested map using a dot-notation path
|
// setNestedValue sets a value in a nested map using a dot-notation path
|
||||||
// It creates intermediate maps if they don't exist
|
// It creates intermediate maps if they don't exist
|
||||||
// If a segment exists but is not a map, it will be overwritten by a new map
|
// If a segment exists but is not a map, it will be overwritten by a new map
|
||||||
func SetNestedValue(nested map[string]any, path string, value any) {
|
func setNestedValue(nested map[string]any, path string, value any) {
|
||||||
segments := strings.Split(path, ".")
|
segments := strings.Split(path, ".")
|
||||||
current := nested
|
current := nested
|
||||||
|
|
||||||
@ -64,8 +64,8 @@ func SetNestedValue(nested map[string]any, path string, value any) {
|
|||||||
current[lastSegment] = value
|
current[lastSegment] = value
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsValidKeySegment checks if a single path segment is a valid TOML key part
|
// isValidKeySegment checks if a single path segment is a valid TOML key part
|
||||||
func IsValidKeySegment(s string) bool {
|
func isValidKeySegment(s string) bool {
|
||||||
if len(s) == 0 {
|
if len(s) == 0 {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
20
loader.go
20
loader.go
@ -62,14 +62,8 @@ func DefaultLoadOptions() LoadOptions {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load reads configuration from a TOML file and merges overrides from command-line arguments.
|
// loadWithOptions loads configuration from multiple sources with custom options
|
||||||
// This is a convenience method that maintains backward compatibility.
|
func (c *Config) loadWithOptions(filePath string, args []string, opts LoadOptions) error {
|
||||||
func (c *Config) Load(filePath string, args []string) error {
|
|
||||||
return c.LoadWithOptions(filePath, args, c.options)
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoadWithOptions loads configuration from multiple sources with custom options
|
|
||||||
func (c *Config) LoadWithOptions(filePath string, args []string, opts LoadOptions) error {
|
|
||||||
c.mutex.Lock()
|
c.mutex.Lock()
|
||||||
c.options = opts
|
c.options = opts
|
||||||
c.mutex.Unlock()
|
c.mutex.Unlock()
|
||||||
@ -143,7 +137,7 @@ func (c *Config) Save(path string) error {
|
|||||||
|
|
||||||
nestedData := make(map[string]any)
|
nestedData := make(map[string]any)
|
||||||
for itemPath, item := range c.items {
|
for itemPath, item := range c.items {
|
||||||
SetNestedValue(nestedData, itemPath, item.currentValue)
|
setNestedValue(nestedData, itemPath, item.currentValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.mutex.RUnlock()
|
c.mutex.RUnlock()
|
||||||
@ -215,7 +209,7 @@ func (c *Config) SaveSource(path string, source Source) error {
|
|||||||
nestedData := make(map[string]any)
|
nestedData := make(map[string]any)
|
||||||
for itemPath, item := range c.items {
|
for itemPath, item := range c.items {
|
||||||
if val, exists := item.values[source]; exists {
|
if val, exists := item.values[source]; exists {
|
||||||
SetNestedValue(nestedData, itemPath, val)
|
setNestedValue(nestedData, itemPath, val)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -501,7 +495,7 @@ func (c *Config) loadCLI(args []string) error {
|
|||||||
return err // Already wrapped with error category in parseArgs
|
return err // Already wrapped with error category in parseArgs
|
||||||
}
|
}
|
||||||
|
|
||||||
flattenedCLI := FlattenMap(parsedCLI, "")
|
flattenedCLI := flattenMap(parsedCLI, "")
|
||||||
if len(flattenedCLI) == 0 {
|
if len(flattenedCLI) == 0 {
|
||||||
return nil // No CLI args to process.
|
return nil // No CLI args to process.
|
||||||
}
|
}
|
||||||
@ -648,13 +642,13 @@ func parseArgs(args []string) (map[string]any, error) {
|
|||||||
// Validate keyPath segments
|
// Validate keyPath segments
|
||||||
segments := strings.Split(keyPath, ".")
|
segments := strings.Split(keyPath, ".")
|
||||||
for _, segment := range segments {
|
for _, segment := range segments {
|
||||||
if !IsValidKeySegment(segment) {
|
if !isValidKeySegment(segment) {
|
||||||
return nil, wrapError(ErrInvalidPath, fmt.Errorf("invalid command-line key segment %q in path %q", segment, keyPath))
|
return nil, wrapError(ErrInvalidPath, fmt.Errorf("invalid command-line key segment %q in path %q", segment, keyPath))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always store as a string. Let Scan handle final type conversion.
|
// Always store as a string. Let Scan handle final type conversion.
|
||||||
SetNestedValue(result, keyPath, valueStr)
|
setNestedValue(result, keyPath, valueStr)
|
||||||
}
|
}
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
|
|||||||
@ -156,7 +156,7 @@ func TestEnvironmentLoading(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
err := cfg.LoadWithOptions("", nil, opts)
|
err := cfg.loadWithOptions("", nil, opts)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
host, _ := cfg.Get("db.host")
|
host, _ := cfg.Get("db.host")
|
||||||
@ -176,7 +176,7 @@ func TestEnvironmentLoading(t *testing.T) {
|
|||||||
EnvWhitelist: map[string]bool{"allowed.path": true},
|
EnvWhitelist: map[string]bool{"allowed.path": true},
|
||||||
}
|
}
|
||||||
|
|
||||||
err := cfg.LoadWithOptions("", nil, opts)
|
err := cfg.loadWithOptions("", nil, opts)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
allowed, _ := cfg.Get("allowed.path")
|
allowed, _ := cfg.Get("allowed.path")
|
||||||
@ -321,7 +321,7 @@ port = 8080
|
|||||||
EnvPrefix: "TEST_",
|
EnvPrefix: "TEST_",
|
||||||
}
|
}
|
||||||
|
|
||||||
err := cfg.LoadWithOptions(configFile, args, opts)
|
err := cfg.loadWithOptions(configFile, args, opts)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// CLI should win
|
// CLI should win
|
||||||
|
|||||||
@ -20,7 +20,7 @@ func (c *Config) Register(path string, defaultValue any) error {
|
|||||||
// Validate path segments
|
// Validate path segments
|
||||||
segments := strings.Split(path, ".")
|
segments := strings.Split(path, ".")
|
||||||
for _, segment := range segments {
|
for _, segment := range segments {
|
||||||
if !IsValidKeySegment(segment) {
|
if !isValidKeySegment(segment) {
|
||||||
return wrapError(ErrInvalidPath, fmt.Errorf("invalid path segment %q in path %q", segment, path))
|
return wrapError(ErrInvalidPath, fmt.Errorf("invalid path segment %q in path %q", segment, path))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
// FILE: lixenwraith/config/convenience.go
|
// FILE: lixenwraith/config/utility.go
|
||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@ -8,6 +8,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/BurntSushi/toml"
|
"github.com/BurntSushi/toml"
|
||||||
"github.com/go-viper/mapstructure/v2"
|
"github.com/go-viper/mapstructure/v2"
|
||||||
@ -29,7 +30,7 @@ func Quick(structDefaults any, envPrefix, configFile string) (*Config, error) {
|
|||||||
opts := DefaultLoadOptions()
|
opts := DefaultLoadOptions()
|
||||||
opts.EnvPrefix = envPrefix
|
opts.EnvPrefix = envPrefix
|
||||||
|
|
||||||
err := cfg.LoadWithOptions(configFile, os.Args[1:], opts)
|
err := cfg.loadWithOptions(configFile, os.Args[1:], opts)
|
||||||
return cfg, err
|
return cfg, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -44,7 +45,7 @@ func QuickCustom(structDefaults any, opts LoadOptions, configFile string) (*Conf
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
err := cfg.LoadWithOptions(configFile, os.Args[1:], opts)
|
err := cfg.loadWithOptions(configFile, os.Args[1:], opts)
|
||||||
return cfg, err
|
return cfg, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -180,7 +181,7 @@ func (c *Config) Dump() error {
|
|||||||
|
|
||||||
nestedData := make(map[string]any)
|
nestedData := make(map[string]any)
|
||||||
for path, item := range c.items {
|
for path, item := range c.items {
|
||||||
SetNestedValue(nestedData, path, item.currentValue)
|
setNestedValue(nestedData, path, item.currentValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
encoder := toml.NewEncoder(os.Stdout)
|
encoder := toml.NewEncoder(os.Stdout)
|
||||||
@ -280,7 +281,7 @@ func GetTyped[T any](c *Config, path string) (T, error) {
|
|||||||
|
|
||||||
// GetTypedWithDefault retrieves a configuration value with a default fallback
|
// GetTypedWithDefault retrieves a configuration value with a default fallback
|
||||||
// If the path doesn't exist or isn't set, it sets and returns the default value
|
// If the path doesn't exist or isn't set, it sets and returns the default value
|
||||||
// This is a convenience function for simple cases where explicit defaults aren't pre-registered
|
// For simple cases where explicit defaults aren't pre-registered
|
||||||
func GetTypedWithDefault[T any](c *Config, path string, defaultValue T) (T, error) {
|
func GetTypedWithDefault[T any](c *Config, path string, defaultValue T) (T, error) {
|
||||||
// Check if path exists and has a value
|
// Check if path exists and has a value
|
||||||
if _, exists := c.Get(path); exists {
|
if _, exists := c.Get(path); exists {
|
||||||
@ -319,3 +320,46 @@ func ScanTyped[T any](c *Config, basePath ...string) (*T, error) {
|
|||||||
}
|
}
|
||||||
return &target, nil
|
return &target, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ScanMap decodes a configuration map directly into a target struct
|
||||||
|
// without requiring a full Config instance. This is useful for plugin
|
||||||
|
// initialization where config data arrives as a map[string]any.
|
||||||
|
func ScanMap(configMap map[string]any, target any, tagName ...string) error {
|
||||||
|
// Handle nil map
|
||||||
|
if configMap == nil {
|
||||||
|
configMap = make(map[string]any)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine tag name
|
||||||
|
tag := "toml" // default
|
||||||
|
if len(tagName) > 0 && tagName[0] != "" {
|
||||||
|
tag = tagName[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create decoder with standard hooks
|
||||||
|
decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
|
||||||
|
Result: target,
|
||||||
|
TagName: tag,
|
||||||
|
WeaklyTypedInput: true,
|
||||||
|
DecodeHook: mapstructure.ComposeDecodeHookFunc(
|
||||||
|
jsonNumberHookFunc(),
|
||||||
|
stringToNetIPHookFunc(),
|
||||||
|
stringToNetIPNetHookFunc(),
|
||||||
|
stringToURLHookFunc(),
|
||||||
|
mapstructure.StringToTimeDurationHookFunc(),
|
||||||
|
mapstructure.StringToTimeHookFunc(time.RFC3339),
|
||||||
|
mapstructure.StringToSliceHookFunc(","),
|
||||||
|
),
|
||||||
|
ZeroFields: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return wrapError(ErrDecode, fmt.Errorf("decoder creation failed: %w", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode directly
|
||||||
|
if err := decoder.Decode(configMap); err != nil {
|
||||||
|
return wrapError(ErrDecode, fmt.Errorf("decode failed: %w", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
// FILE: lixenwraith/config/convenience_test.go
|
// FILE: lixenwraith/config/utility_test.go
|
||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@ -12,7 +12,7 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestQuickFunctions tests the convenience Quick* functions
|
// TestQuickFunctions tests the utility Quick* functions
|
||||||
func TestQuickFunctions(t *testing.T) {
|
func TestQuickFunctions(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
configFile := filepath.Join(tmpDir, "quick.toml")
|
configFile := filepath.Join(tmpDir, "quick.toml")
|
||||||
@ -371,3 +371,99 @@ func TestGetTypedWithDefault(t *testing.T) {
|
|||||||
assert.Equal(t, []string{"default", "tag"}, tags)
|
assert.Equal(t, []string{"default", "tag"}, tags)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestScanMap tests the ScanMap utility function
|
||||||
|
func TestScanMap(t *testing.T) {
|
||||||
|
type Config struct {
|
||||||
|
Server struct {
|
||||||
|
Host string `toml:"host" json:"hostname"`
|
||||||
|
Port int `toml:"port" json:"port"`
|
||||||
|
Timeout time.Duration `toml:"timeout" json:"timeout"`
|
||||||
|
} `toml:"server" json:"server"`
|
||||||
|
LogLevel string `toml:"log_level" json:"logLevel"`
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("BasicScanWithTOMLTags", func(t *testing.T) {
|
||||||
|
configMap := map[string]any{
|
||||||
|
"server": map[string]any{
|
||||||
|
"host": "localhost",
|
||||||
|
"port": 8080,
|
||||||
|
"timeout": "15s",
|
||||||
|
},
|
||||||
|
"log_level": "info",
|
||||||
|
}
|
||||||
|
|
||||||
|
var target Config
|
||||||
|
err := ScanMap(configMap, &target)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "localhost", target.Server.Host)
|
||||||
|
assert.Equal(t, 8080, target.Server.Port)
|
||||||
|
assert.Equal(t, 15*time.Second, target.Server.Timeout)
|
||||||
|
assert.Equal(t, "info", target.LogLevel)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ScanWithJSONTags", func(t *testing.T) {
|
||||||
|
configMap := map[string]any{
|
||||||
|
"server": map[string]any{
|
||||||
|
"hostname": "json-host",
|
||||||
|
"port": 9090,
|
||||||
|
"timeout": "1m",
|
||||||
|
},
|
||||||
|
"logLevel": "debug",
|
||||||
|
}
|
||||||
|
|
||||||
|
var target Config
|
||||||
|
err := ScanMap(configMap, &target, "json")
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "json-host", target.Server.Host)
|
||||||
|
assert.Equal(t, 9090, target.Server.Port)
|
||||||
|
assert.Equal(t, 1*time.Minute, target.Server.Timeout)
|
||||||
|
assert.Equal(t, "debug", target.LogLevel)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("NilMapInput", func(t *testing.T) {
|
||||||
|
var target Config
|
||||||
|
target.LogLevel = "initial"
|
||||||
|
target.Server.Port = 1234
|
||||||
|
|
||||||
|
err := ScanMap(nil, &target)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify that fields are NOT changed when the map is empty,
|
||||||
|
// reflecting the observed behavior.
|
||||||
|
assert.Equal(t, "initial", target.LogLevel)
|
||||||
|
assert.Equal(t, 1234, target.Server.Port)
|
||||||
|
assert.Empty(t, target.Server.Host)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("PartialMapBehavior", func(t *testing.T) {
|
||||||
|
configMap := map[string]any{
|
||||||
|
"log_level": "warn",
|
||||||
|
}
|
||||||
|
var target Config
|
||||||
|
target.Server.Host = "initial_host"
|
||||||
|
target.Server.Port = 1234
|
||||||
|
target.LogLevel = "initial_log"
|
||||||
|
|
||||||
|
err := ScanMap(configMap, &target)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Mapped field should be updated
|
||||||
|
assert.Equal(t, "warn", target.LogLevel)
|
||||||
|
// Unmapped fields should be untouched
|
||||||
|
assert.Equal(t, "initial_host", target.Server.Host, "Unmapped field should be untouched")
|
||||||
|
assert.Equal(t, 1234, target.Server.Port, "Unmapped field should be untouched")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("InvalidTarget", func(t *testing.T) {
|
||||||
|
configMap := map[string]any{"log_level": "info"}
|
||||||
|
var target Config // Not a pointer
|
||||||
|
|
||||||
|
err := ScanMap(configMap, target)
|
||||||
|
assert.Error(t, err)
|
||||||
|
// The underlying mapstructure error is "result must be a pointer"
|
||||||
|
assert.Contains(t, err.Error(), "must be a pointer")
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -32,7 +32,6 @@ const (
|
|||||||
testWatchTimeout = 2 * DefaultPollInterval // 2s for change propagation
|
testWatchTimeout = 2 * DefaultPollInterval // 2s for change propagation
|
||||||
|
|
||||||
// Derived test multipliers with clear purpose
|
// Derived test multipliers with clear purpose
|
||||||
testDebounceSettle = debounceSettleMultiplier * testDebounce // 150ms for debounce verification
|
|
||||||
testPollWindow = 3 * testPollInterval // 300ms change detection window
|
testPollWindow = 3 * testPollInterval // 300ms change detection window
|
||||||
testStateStabilize = 4 * testDebounce // 200ms for state convergence
|
testStateStabilize = 4 * testDebounce // 200ms for state convergence
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user