diff --git a/config.go b/config.go index da5dcaf..a183d48 100644 --- a/config.go +++ b/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 diff --git a/config_test.go b/config_test.go index 4bbd867..5fc8c03 100644 --- a/config_test.go +++ b/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 { diff --git a/go.mod b/go.mod index 83d5b1d..6c2eac3 100644 --- a/go.mod +++ b/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