e6.1.0 Added precedence modification and tests.

This commit is contained in:
2025-09-01 16:10:21 -04:00
parent 112426b43f
commit 406df7e8b7
3 changed files with 335 additions and 1 deletions

View File

@ -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

View File

@ -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
View File

@ -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