e6.1.0 Added precedence modification and tests.
This commit is contained in:
71
config.go
71
config.go
@ -116,6 +116,77 @@ func (c *Config) SetLoadOptions(opts LoadOptions) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetPrecedence updates source precedence with validation
|
||||||
|
func (c *Config) SetPrecedence(sources ...Source) error {
|
||||||
|
// Validate all required sources present
|
||||||
|
required := map[Source]bool{
|
||||||
|
SourceDefault: false,
|
||||||
|
SourceFile: false,
|
||||||
|
SourceEnv: false,
|
||||||
|
SourceCLI: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, s := range sources {
|
||||||
|
if _, valid := required[s]; !valid {
|
||||||
|
return fmt.Errorf("invalid source: %s", s)
|
||||||
|
}
|
||||||
|
required[s] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure SourceDefault is included
|
||||||
|
if !required[SourceDefault] {
|
||||||
|
sources = append(sources, SourceDefault)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mutex.Lock()
|
||||||
|
defer c.mutex.Unlock()
|
||||||
|
|
||||||
|
// FIXED: Check if precedence actually changed
|
||||||
|
oldPrecedence := c.options.Sources
|
||||||
|
if reflect.DeepEqual(oldPrecedence, sources) {
|
||||||
|
return nil // No change needed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track value changes before updating precedence
|
||||||
|
oldValues := make(map[string]any)
|
||||||
|
for path, item := range c.items {
|
||||||
|
oldValues[path] = item.currentValue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update precedence
|
||||||
|
c.options.Sources = sources
|
||||||
|
|
||||||
|
// Recompute values and track changes
|
||||||
|
changedPaths := make([]string, 0)
|
||||||
|
for path, item := range c.items {
|
||||||
|
item.currentValue = c.computeValue(item)
|
||||||
|
if !reflect.DeepEqual(oldValues[path], item.currentValue) {
|
||||||
|
changedPaths = append(changedPaths, path)
|
||||||
|
}
|
||||||
|
c.items[path] = item
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify watchers of precedence change
|
||||||
|
if c.watcher != nil && len(changedPaths) > 0 {
|
||||||
|
for _, path := range changedPaths {
|
||||||
|
c.watcher.notifyWatchers("precedence:" + path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.invalidateCache()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPrecedence returns current source precedence
|
||||||
|
func (c *Config) GetPrecedence() []Source {
|
||||||
|
c.mutex.RLock()
|
||||||
|
defer c.mutex.RUnlock()
|
||||||
|
|
||||||
|
result := make([]Source, len(c.options.Sources))
|
||||||
|
copy(result, c.options.Sources)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
// computeValue determines the current value based on precedence
|
// computeValue determines the current value based on precedence
|
||||||
func (c *Config) computeValue(item configItem) any {
|
func (c *Config) computeValue(item configItem) any {
|
||||||
// Check sources in precedence order
|
// Check sources in precedence order
|
||||||
|
|||||||
263
config_test.go
263
config_test.go
@ -5,6 +5,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@ -185,6 +187,267 @@ func TestSourcePrecedence(t *testing.T) {
|
|||||||
assert.Equal(t, "from-env", sources[SourceEnv])
|
assert.Equal(t, "from-env", sources[SourceEnv])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestSetPrecedence tests runtime precedence switching
|
||||||
|
func TestSetPrecedence(t *testing.T) {
|
||||||
|
t.Run("BasicPrecedenceSwitch", func(t *testing.T) {
|
||||||
|
cfg := New()
|
||||||
|
cfg.Register("test.value", "default")
|
||||||
|
|
||||||
|
// Set different values in each source
|
||||||
|
cfg.SetSource(SourceFile, "test.value", "from-file")
|
||||||
|
cfg.SetSource(SourceEnv, "test.value", "from-env")
|
||||||
|
cfg.SetSource(SourceCLI, "test.value", "from-cli")
|
||||||
|
|
||||||
|
// Default precedence: CLI > Env > File > Default
|
||||||
|
val, _ := cfg.Get("test.value")
|
||||||
|
assert.Equal(t, "from-cli", val)
|
||||||
|
|
||||||
|
// Switch to File > CLI > Env > Default
|
||||||
|
err := cfg.SetPrecedence(SourceFile, SourceCLI, SourceEnv, SourceDefault)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
val, _ = cfg.Get("test.value")
|
||||||
|
assert.Equal(t, "from-file", val)
|
||||||
|
|
||||||
|
// Verify precedence was updated
|
||||||
|
precedence := cfg.GetPrecedence()
|
||||||
|
assert.Equal(t, []Source{SourceFile, SourceCLI, SourceEnv, SourceDefault}, precedence)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("NoPrecedenceChangeOptimization", func(t *testing.T) {
|
||||||
|
cfg := New()
|
||||||
|
cfg.Register("test.value", "default")
|
||||||
|
cfg.SetSource(SourceFile, "test.value", "from-file")
|
||||||
|
|
||||||
|
// Set same precedence
|
||||||
|
initialPrecedence := cfg.GetPrecedence()
|
||||||
|
err := cfg.SetPrecedence(initialPrecedence...)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Should be no-op, verify by checking version
|
||||||
|
version1 := cfg.version.Load()
|
||||||
|
err = cfg.SetPrecedence(initialPrecedence...)
|
||||||
|
require.NoError(t, err)
|
||||||
|
version2 := cfg.version.Load()
|
||||||
|
|
||||||
|
assert.Equal(t, version1, version2, "Version should not change on no-op")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("AutoAddDefaultSource", func(t *testing.T) {
|
||||||
|
cfg := New()
|
||||||
|
|
||||||
|
// Set precedence without SourceDefault
|
||||||
|
err := cfg.SetPrecedence(SourceCLI, SourceFile, SourceEnv)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// SourceDefault should be auto-appended
|
||||||
|
precedence := cfg.GetPrecedence()
|
||||||
|
assert.Equal(t, []Source{SourceCLI, SourceFile, SourceEnv, SourceDefault}, precedence)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("InvalidSourceError", func(t *testing.T) {
|
||||||
|
cfg := New()
|
||||||
|
|
||||||
|
// Try to set invalid source
|
||||||
|
err := cfg.SetPrecedence(Source("invalid"), SourceFile)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "invalid source")
|
||||||
|
|
||||||
|
// Precedence should remain unchanged
|
||||||
|
precedence := cfg.GetPrecedence()
|
||||||
|
assert.Equal(t, []Source{SourceCLI, SourceEnv, SourceFile, SourceDefault}, precedence)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("PrecedenceChangeNotifications", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configFile := filepath.Join(tmpDir, "test.toml")
|
||||||
|
os.WriteFile(configFile, []byte(`value = "from-file"`), 0644)
|
||||||
|
|
||||||
|
cfg := New()
|
||||||
|
cfg.Register("value", "default")
|
||||||
|
cfg.LoadFile(configFile)
|
||||||
|
cfg.SetSource(SourceCLI, "value", "from-cli")
|
||||||
|
|
||||||
|
// Enable watching
|
||||||
|
opts := WatchOptions{
|
||||||
|
PollInterval: 100 * time.Millisecond,
|
||||||
|
Debounce: 50 * time.Millisecond,
|
||||||
|
}
|
||||||
|
cfg.AutoUpdateWithOptions(opts)
|
||||||
|
defer cfg.StopAutoUpdate()
|
||||||
|
|
||||||
|
// Start watching for changes
|
||||||
|
changes := cfg.Watch()
|
||||||
|
|
||||||
|
// Change precedence - should trigger notification
|
||||||
|
go func() {
|
||||||
|
time.Sleep(50 * time.Millisecond) // Let watcher start
|
||||||
|
cfg.SetPrecedence(SourceFile, SourceCLI, SourceEnv, SourceDefault)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait for precedence change notification
|
||||||
|
select {
|
||||||
|
case change := <-changes:
|
||||||
|
assert.Equal(t, "precedence:value", change)
|
||||||
|
|
||||||
|
// Verify value changed
|
||||||
|
val, _ := cfg.Get("value")
|
||||||
|
assert.Equal(t, "from-file", val)
|
||||||
|
case <-time.After(500 * time.Millisecond):
|
||||||
|
t.Error("Timeout waiting for precedence change notification")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("MultipleValuesAffected", func(t *testing.T) {
|
||||||
|
cfg := New()
|
||||||
|
paths := []string{"app.name", "app.version", "app.debug"}
|
||||||
|
|
||||||
|
for _, path := range paths {
|
||||||
|
cfg.Register(path, "default-"+path)
|
||||||
|
cfg.SetSource(SourceFile, path, "file-"+path)
|
||||||
|
cfg.SetSource(SourceEnv, path, "env-"+path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial state: Env wins
|
||||||
|
cfg.SetPrecedence(SourceEnv, SourceFile, SourceDefault)
|
||||||
|
for _, path := range paths {
|
||||||
|
val, _ := cfg.Get(path)
|
||||||
|
assert.Equal(t, "env-"+path, val)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Switch: File wins
|
||||||
|
err := cfg.SetPrecedence(SourceFile, SourceEnv, SourceDefault)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
for _, path := range paths {
|
||||||
|
val, _ := cfg.Get(path)
|
||||||
|
assert.Equal(t, "file-"+path, val)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ConcurrentPrecedenceChanges", func(t *testing.T) {
|
||||||
|
cfg := New()
|
||||||
|
cfg.Register("test", "default")
|
||||||
|
cfg.SetSource(SourceFile, "test", "file")
|
||||||
|
cfg.SetSource(SourceCLI, "test", "cli")
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
errors := make(chan error, 20)
|
||||||
|
|
||||||
|
// Multiple goroutines changing precedence
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(id int) {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
var sources []Source
|
||||||
|
if id%2 == 0 {
|
||||||
|
sources = []Source{SourceFile, SourceCLI, SourceDefault}
|
||||||
|
} else {
|
||||||
|
sources = []Source{SourceCLI, SourceFile, SourceDefault}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := cfg.SetPrecedence(sources...); err != nil {
|
||||||
|
errors <- err
|
||||||
|
}
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Concurrent reads during precedence changes
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
val, exists := cfg.Get("test")
|
||||||
|
if !exists {
|
||||||
|
errors <- fmt.Errorf("value not found during concurrent access")
|
||||||
|
}
|
||||||
|
// Value should be either "file" or "cli"
|
||||||
|
if val != "file" && val != "cli" {
|
||||||
|
errors <- fmt.Errorf("unexpected value: %v", val)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
close(errors)
|
||||||
|
|
||||||
|
// Check for errors
|
||||||
|
var errs []error
|
||||||
|
for err := range errors {
|
||||||
|
errs = append(errs, err)
|
||||||
|
}
|
||||||
|
assert.Empty(t, errs, "Concurrent precedence changes should not produce errors")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPrecedenceWithAutoUpdate verifies no conflicts between precedence and auto-update
|
||||||
|
func TestPrecedenceWithAutoUpdate(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configFile := filepath.Join(tmpDir, "test.toml")
|
||||||
|
|
||||||
|
// Initial file content
|
||||||
|
os.WriteFile(configFile, []byte(`
|
||||||
|
server = "file-server-1"
|
||||||
|
port = 8080
|
||||||
|
`), 0644)
|
||||||
|
|
||||||
|
cfg := New()
|
||||||
|
cfg.Register("server", "default-server")
|
||||||
|
cfg.Register("port", 0)
|
||||||
|
|
||||||
|
// Load with CLI override
|
||||||
|
cfg.LoadFile(configFile)
|
||||||
|
cfg.SetSource(SourceCLI, "server", "cli-server")
|
||||||
|
|
||||||
|
// CLI wins initially
|
||||||
|
val, _ := cfg.Get("server")
|
||||||
|
assert.Equal(t, "cli-server", val)
|
||||||
|
|
||||||
|
// Enable auto-update
|
||||||
|
opts := WatchOptions{
|
||||||
|
PollInterval: 100 * time.Millisecond,
|
||||||
|
Debounce: 50 * time.Millisecond,
|
||||||
|
}
|
||||||
|
cfg.AutoUpdateWithOptions(opts)
|
||||||
|
defer cfg.StopAutoUpdate()
|
||||||
|
|
||||||
|
// Switch precedence to File > CLI
|
||||||
|
err := cfg.SetPrecedence(SourceFile, SourceCLI, SourceEnv, SourceDefault)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// File should now win
|
||||||
|
val, _ = cfg.Get("server")
|
||||||
|
assert.Equal(t, "file-server-1", val)
|
||||||
|
|
||||||
|
// Update file
|
||||||
|
os.WriteFile(configFile, []byte(`
|
||||||
|
server = "file-server-2"
|
||||||
|
port = 9090
|
||||||
|
`), 0644)
|
||||||
|
|
||||||
|
// Wait for auto-update
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
|
||||||
|
// File still wins with new value
|
||||||
|
val, _ = cfg.Get("server")
|
||||||
|
assert.Equal(t, "file-server-2", val)
|
||||||
|
|
||||||
|
// CLI value is preserved but not active
|
||||||
|
cliVal, exists := cfg.GetSource("server", SourceCLI)
|
||||||
|
assert.True(t, exists)
|
||||||
|
assert.Equal(t, "cli-server", cliVal)
|
||||||
|
|
||||||
|
// Switch back to CLI > File
|
||||||
|
err = cfg.SetPrecedence(SourceCLI, SourceFile, SourceEnv, SourceDefault)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// CLI wins again
|
||||||
|
val, _ = cfg.Get("server")
|
||||||
|
assert.Equal(t, "cli-server", val)
|
||||||
|
}
|
||||||
|
|
||||||
// TestTypeConversion tests automatic type conversion through mapstructure
|
// TestTypeConversion tests automatic type conversion through mapstructure
|
||||||
func TestTypeConversion(t *testing.T) {
|
func TestTypeConversion(t *testing.T) {
|
||||||
type TestConfig struct {
|
type TestConfig struct {
|
||||||
|
|||||||
2
go.mod
2
go.mod
@ -6,12 +6,12 @@ require (
|
|||||||
github.com/BurntSushi/toml v1.5.0
|
github.com/BurntSushi/toml v1.5.0
|
||||||
github.com/mitchellh/mapstructure v1.5.0
|
github.com/mitchellh/mapstructure v1.5.0
|
||||||
github.com/stretchr/testify v1.10.0
|
github.com/stretchr/testify v1.10.0
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
|
||||||
)
|
)
|
||||||
|
|
||||||
replace github.com/mitchellh/mapstructure => github.com/go-viper/mapstructure v1.6.0
|
replace github.com/mitchellh/mapstructure => github.com/go-viper/mapstructure v1.6.0
|
||||||
|
|||||||
Reference in New Issue
Block a user