fix(config): make iteration deterministic

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-03-31 18:09:32 +00:00
parent 6dd94aff4c
commit d6f7c05838
3 changed files with 78 additions and 5 deletions

View file

@ -13,9 +13,9 @@ package config
import (
"fmt"
"iter"
"maps"
"os"
"path/filepath"
"sort"
"strings"
"sync"
@ -200,11 +200,26 @@ func (c *Config) Commit() error {
return nil
}
// All returns an iterator over all configuration values (including environment variables).
// All returns an iterator over all configuration values in lexical key order
// (including environment variables).
func (c *Config) All() iter.Seq2[string, any] {
c.mu.RLock()
defer c.mu.RUnlock()
return maps.All(c.v.AllSettings())
settings := c.v.AllSettings()
keys := make([]string, 0, len(settings))
for key := range settings {
keys = append(keys, key)
}
sort.Strings(keys)
return func(yield func(string, any) bool) {
for _, key := range keys {
if !yield(key, settings[key]) {
return
}
}
}
}
// Path returns the path to the configuration file.

View file

@ -90,6 +90,23 @@ func TestConfig_All_Good(t *testing.T) {
assert.Equal(t, "val2", all["key2"])
}
func TestConfig_All_Order_Good(t *testing.T) {
m := coreio.NewMockMedium()
cfg, err := New(WithMedium(m), WithPath("/tmp/test/config.yaml"))
assert.NoError(t, err)
_ = cfg.Set("zulu", "last")
_ = cfg.Set("alpha", "first")
var keys []string
for key, _ := range cfg.All() {
keys = append(keys, key)
}
assert.Equal(t, []string{"alpha", "zulu"}, keys)
}
func TestConfig_Path_Good(t *testing.T) {
m := coreio.NewMockMedium()
@ -199,6 +216,21 @@ func TestLoadEnv_Good(t *testing.T) {
assert.Equal(t, "value", result["simple"])
}
func TestLoadEnv_PrefixNormalisation_Good(t *testing.T) {
t.Setenv("MYAPP_SETTING", "secret")
t.Setenv("MYAPP_ALPHA", "first")
keys := make([]string, 0, 2)
values := make([]string, 0, 2)
for key, value := range Env("MYAPP") {
keys = append(keys, key)
values = append(values, value.(string))
}
assert.Equal(t, []string{"alpha", "setting"}, keys)
assert.Equal(t, []string{"first", "secret"}, values)
}
func TestLoad_Bad(t *testing.T) {
m := coreio.NewMockMedium()

30
env.go
View file

@ -3,17 +3,36 @@ package config
import (
"iter"
"os"
"sort"
"strings"
)
func normaliseEnvPrefix(prefix string) string {
if prefix == "" || strings.HasSuffix(prefix, "_") {
return prefix
}
return prefix + "_"
}
// Env returns an iterator over environment variables with the given prefix,
// providing them as dot-notation keys and values.
//
// The prefix may be supplied with or without a trailing underscore.
//
// For example, with prefix "CORE_CONFIG_":
//
// CORE_CONFIG_FOO_BAR=baz -> yields ("foo.bar", "baz")
func Env(prefix string) iter.Seq2[string, any] {
return func(yield func(string, any) bool) {
prefix = normaliseEnvPrefix(prefix)
type entry struct {
key string
value any
}
var entries []entry
for _, env := range os.Environ() {
if !strings.HasPrefix(env, prefix) {
continue
@ -27,12 +46,19 @@ func Env(prefix string) iter.Seq2[string, any] {
name := parts[0]
value := parts[1]
// Strip prefix and convert to dot notation
key := strings.TrimPrefix(name, prefix)
key = strings.ToLower(key)
key = strings.ReplaceAll(key, "_", ".")
if !yield(key, value) {
entries = append(entries, entry{key: key, value: value})
}
sort.Slice(entries, func(i, j int) bool {
return entries[i].key < entries[j].key
})
for _, entry := range entries {
if !yield(entry.key, entry.value) {
return
}
}