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
|
||||
}
|
||||
|
||||
// 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
|
||||
func (c *Config) computeValue(item configItem) any {
|
||||
// Check sources in precedence order
|
||||
|
||||
263
config_test.go
263
config_test.go
@ -5,6 +5,8 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
@ -185,6 +187,267 @@ func TestSourcePrecedence(t *testing.T) {
|
||||
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
|
||||
func TestTypeConversion(t *testing.T) {
|
||||
type TestConfig struct {
|
||||
|
||||
2
go.mod
2
go.mod
@ -6,12 +6,12 @@ require (
|
||||
github.com/BurntSushi/toml v1.5.0
|
||||
github.com/mitchellh/mapstructure v1.5.0
|
||||
github.com/stretchr/testify v1.10.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.1 // 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
|
||||
|
||||
Reference in New Issue
Block a user