diff --git a/config.go b/config.go index 50af7d9..9952b40 100644 --- a/config.go +++ b/config.go @@ -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. diff --git a/config_test.go b/config_test.go index cfc376e..f53ff3e 100644 --- a/config_test.go +++ b/config_test.go @@ -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() diff --git a/env.go b/env.go index 64c0372..eff8519 100644 --- a/env.go +++ b/env.go @@ -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 } }