From 9442e00792b71574ffcf14c3f9892f21f36d1afa Mon Sep 17 00:00:00 2001 From: h-dav Date: Sun, 3 May 2026 16:24:14 +0100 Subject: [PATCH] feat(config): add config dump/logging with WithSummary option Adds opt-in provenance tracking for configuration resolution, exposing what was loaded and from which source. Users can now inspect LoadSummary to debug config issues and understand source precedence. New public types: - LoadEntry: describes a resolved config field and its source - LoadSummary: collection of LoadEntry records New option: - WithSummary(&summary): populates summary with provenance info New source interface method: - Name() string: returns "env", "flag", or file path Implementation tracks per-key provenance during merge and appends LoadEntry records when fields are populated from sources or defaults. Includes 11 comprehensive test cases covering all source types, precedence, nested structs, and variable substitution. --- README.md | 32 +++++ configutil.go | 24 +++- configutil_test.go | 307 ++++++++++++++++++++++++++++++++++++++++++--- populate.go | 18 ++- settings.go | 6 +- source.go | 13 ++ summary.go | 17 +++ 7 files changed, 392 insertions(+), 25 deletions(-) create mode 100644 summary.go diff --git a/README.md b/README.md index 92a46bc..8fdff43 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ if err := configutil.Set(&cfg); err != nil { | Option | Description | |--------------------------------|------------------------------------| | `WithFilepath("config.env")` | Load values from a `.env` file. | +| `WithSummary(&summary)` | Populate a `LoadSummary` with provenance info for each resolved field. | ### Struct Tags @@ -71,6 +72,37 @@ type Config struct { // Reads SERVER_PORT from sources. ``` +### Config Dump / Logging + +Use `WithSummary` to inspect what was loaded and from which source. This is invaluable for debugging configuration resolution issues. + +```go +type Config struct { + Host string `config:"HOST,default=localhost"` + Port int `config:"PORT"` +} + +var cfg Config +var summary configutil.LoadSummary + +if err := configutil.Set(&cfg, configutil.WithSummary(&summary)); err != nil { + log.Fatal(err) +} + +for _, entry := range summary.Entries { + fmt.Printf("%s=%s (from %s)\n", entry.Key, entry.Value, entry.Source) +} +// Output: +// HOST=localhost (from default) +// PORT=8080 (from env) +``` + +Each `LoadEntry` in the summary contains: +- `FieldName` — the Go struct field name +- `Key` — the config key that was looked up +- `Value` — the final resolved value (after `${VAR}` substitution) +- `Source` — where the value came from (`"env"`, `"flag"`, a file path, or `"default"`) + ## Precedence Sources are evaluated in order. Later sources overwrite earlier ones. diff --git a/configutil.go b/configutil.go index c6f5fa2..df4b6cb 100644 --- a/configutil.go +++ b/configutil.go @@ -1,10 +1,6 @@ // Package configutil populates a struct from environment variables, flags, and .env files. package configutil -import ( - "maps" -) - // Option configures the behaviour of [Set]. type Option func(*settings) @@ -16,12 +12,25 @@ func WithFilepath(path string) Option { } } +// WithSummary registers out to receive provenance information after Set returns. +// Each config field that receives a value will have a corresponding LoadEntry +// in out.Entries describing the field name, key, resolved value, and source. +// WithSummary(nil) is a no-op. +func WithSummary(out *LoadSummary) Option { + return func(s *settings) { + if out != nil { + s.summary = out + } + } +} + // Set populates config from the registered sources. // Sources are evaluated in order: files, environment variables, flags. // Later sources overwrite earlier ones. func Set(config any, opts ...Option) error { s := &settings{ - source: make(map[string]string), + source: make(map[string]string), + provenance: make(map[string]string), } for _, opt := range opts { @@ -37,7 +46,10 @@ func Set(config any, opts ...Option) error { return err } - maps.Copy(s.source, values) + for k, v := range values { + s.source[k] = v + s.provenance[k] = src.Name() + } } return s.populateStruct(config) diff --git a/configutil_test.go b/configutil_test.go index b723e88..f6da68b 100644 --- a/configutil_test.go +++ b/configutil_test.go @@ -11,10 +11,6 @@ import ( "github.com/h-dav/configutil" ) -// --------------------------------------------------------------------------- -// Set — basic functionality -// --------------------------------------------------------------------------- - func TestSet(t *testing.T) { t.Run("basic types", func(t *testing.T) { type Config struct { @@ -575,10 +571,6 @@ func TestSet(t *testing.T) { }) } -// --------------------------------------------------------------------------- -// Set — invalid config types -// --------------------------------------------------------------------------- - func TestSet_InvalidConfig(t *testing.T) { t.Run("not a pointer", func(t *testing.T) { type Config struct { @@ -606,10 +598,6 @@ func TestSet_InvalidConfig(t *testing.T) { }) } -// --------------------------------------------------------------------------- -// WithFilepath -// --------------------------------------------------------------------------- - func TestSet_WithFilepath(t *testing.T) { t.Run("loads values from env file", func(t *testing.T) { type Config struct { @@ -699,10 +687,6 @@ func TestSet_WithFilepath(t *testing.T) { }) } -// --------------------------------------------------------------------------- -// Precedence: Default < File < Env < Flag -// --------------------------------------------------------------------------- - func TestPrecedence(t *testing.T) { if flag.Lookup("PREC_FLAG") == nil { flag.String("PREC_FLAG", "", "test flag for precedence") @@ -814,3 +798,294 @@ func TestPrecedence(t *testing.T) { } }) } + +func TestWithSummary(t *testing.T) { + t.Run("env source", func(t *testing.T) { + type Config struct { + MyValue string `config:"MYKEY"` + } + + t.Setenv("MYKEY", "myval") + + var cfg Config + var summary configutil.LoadSummary + if err := configutil.Set(&cfg, configutil.WithSummary(&summary)); err != nil { + t.Fatalf("Set() error = %v", err) + } + + if len(summary.Entries) != 1 { + t.Fatalf("expected 1 entry, got %d", len(summary.Entries)) + } + + entry := summary.Entries[0] + if entry.FieldName != "MyValue" || entry.Key != "MYKEY" || entry.Value != "myval" || entry.Source != "env" { + t.Errorf("entry = %+v, want {FieldName: MyValue, Key: MYKEY, Value: myval, Source: env}", entry) + } + }) + + t.Run("file source", func(t *testing.T) { + type Config struct { + FileVal string `config:"FILE_KEY"` + } + + tmpFile := t.TempDir() + "/test.env" + if err := os.WriteFile(tmpFile, []byte("FILE_KEY=file_value\n"), 0o644); err != nil { + t.Fatal(err) + } + + var cfg Config + var summary configutil.LoadSummary + if err := configutil.Set(&cfg, configutil.WithFilepath(tmpFile), configutil.WithSummary(&summary)); err != nil { + t.Fatalf("Set() error = %v", err) + } + + if len(summary.Entries) != 1 { + t.Fatalf("expected 1 entry, got %d", len(summary.Entries)) + } + + entry := summary.Entries[0] + if entry.FieldName != "FileVal" || entry.Key != "FILE_KEY" || entry.Value != "file_value" || entry.Source != tmpFile { + t.Errorf("entry = %+v, want {FieldName: FileVal, Key: FILE_KEY, Value: file_value, Source: %s}", entry, tmpFile) + } + }) + + t.Run("default source", func(t *testing.T) { + type Config struct { + WithDefault string `config:"NONEXISTENT,default=fallback"` + } + + var cfg Config + var summary configutil.LoadSummary + if err := configutil.Set(&cfg, configutil.WithSummary(&summary)); err != nil { + t.Fatalf("Set() error = %v", err) + } + + if len(summary.Entries) != 1 { + t.Fatalf("expected 1 entry, got %d", len(summary.Entries)) + } + + entry := summary.Entries[0] + if entry.FieldName != "WithDefault" || entry.Key != "NONEXISTENT" || entry.Value != "fallback" || entry.Source != "default" { + t.Errorf("entry = %+v, want {FieldName: WithDefault, Key: NONEXISTENT, Value: fallback, Source: default}", entry) + } + }) + + t.Run("env overwrites file", func(t *testing.T) { + type Config struct { + Key string `config:"SHARED_KEY"` + } + + tmpFile := t.TempDir() + "/test.env" + if err := os.WriteFile(tmpFile, []byte("SHARED_KEY=file_val\n"), 0o644); err != nil { + t.Fatal(err) + } + + t.Setenv("SHARED_KEY", "env_val") + + var cfg Config + var summary configutil.LoadSummary + if err := configutil.Set(&cfg, configutil.WithFilepath(tmpFile), configutil.WithSummary(&summary)); err != nil { + t.Fatalf("Set() error = %v", err) + } + + if len(summary.Entries) != 1 { + t.Fatalf("expected 1 entry, got %d", len(summary.Entries)) + } + + entry := summary.Entries[0] + if entry.Value != "env_val" || entry.Source != "env" { + t.Errorf("entry = %+v, want Value=env_val and Source=env", entry) + } + }) + + t.Run("flag source", func(t *testing.T) { + type Config struct { + Port int `config:"PORT"` + } + + oldCommandLine := flag.CommandLine + flag.CommandLine = flag.NewFlagSet("test", flag.ContinueOnError) + defer func() { flag.CommandLine = oldCommandLine }() + + flag.CommandLine.String("PORT", "8080", "") + flag.CommandLine.String("UNRELATED", "value", "") + _ = flag.CommandLine.Parse([]string{"-PORT=9000"}) + + var cfg Config + var summary configutil.LoadSummary + if err := configutil.Set(&cfg, configutil.WithSummary(&summary)); err != nil { + t.Fatalf("Set() error = %v", err) + } + + if len(summary.Entries) != 1 { + t.Fatalf("expected 1 entry, got %d", len(summary.Entries)) + } + + entry := summary.Entries[0] + if entry.Key != "PORT" || entry.Value != "9000" || entry.Source != "flag" { + t.Errorf("entry = %+v, want Key=PORT, Value=9000, Source=flag", entry) + } + }) + + t.Run("field not set is absent", func(t *testing.T) { + type Config struct { + Set string `config:"SET_KEY"` + Unset string `config:"UNSET_KEY"` + } + + t.Setenv("SET_KEY", "value") + + var cfg Config + var summary configutil.LoadSummary + if err := configutil.Set(&cfg, configutil.WithSummary(&summary)); err != nil { + t.Fatalf("Set() error = %v", err) + } + + if len(summary.Entries) != 1 { + t.Fatalf("expected 1 entry (only Set), got %d", len(summary.Entries)) + } + + if summary.Entries[0].FieldName != "Set" { + t.Errorf("entry FieldName = %s, want Set", summary.Entries[0].FieldName) + } + }) + + t.Run("nested struct entries", func(t *testing.T) { + type Server struct { + Host string `config:"HOST"` + Port int `config:"PORT"` + } + + type Config struct { + Server Server `config:",prefix=SERVER_"` + } + + t.Setenv("SERVER_HOST", "localhost") + t.Setenv("SERVER_PORT", "8080") + + var cfg Config + var summary configutil.LoadSummary + if err := configutil.Set(&cfg, configutil.WithSummary(&summary)); err != nil { + t.Fatalf("Set() error = %v", err) + } + + if len(summary.Entries) != 2 { + t.Fatalf("expected 2 entries, got %d", len(summary.Entries)) + } + + keys := []string{summary.Entries[0].Key, summary.Entries[1].Key} + if !slices.Contains(keys, "SERVER_HOST") || !slices.Contains(keys, "SERVER_PORT") { + t.Errorf("keys = %v, want [SERVER_HOST SERVER_PORT]", keys) + } + }) + + t.Run("multiple files attributed", func(t *testing.T) { + type Config struct { + FileA string `config:"KEY_A"` + FileB string `config:"KEY_B"` + } + + tmpFileA := t.TempDir() + "/a.env" + tmpFileB := t.TempDir() + "/b.env" + if err := os.WriteFile(tmpFileA, []byte("KEY_A=value_a\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(tmpFileB, []byte("KEY_B=value_b\n"), 0o644); err != nil { + t.Fatal(err) + } + + var cfg Config + var summary configutil.LoadSummary + if err := configutil.Set(&cfg, configutil.WithFilepath(tmpFileA), configutil.WithFilepath(tmpFileB), configutil.WithSummary(&summary)); err != nil { + t.Fatalf("Set() error = %v", err) + } + + if len(summary.Entries) != 2 { + t.Fatalf("expected 2 entries, got %d", len(summary.Entries)) + } + + sources := map[string]string{} + for _, entry := range summary.Entries { + sources[entry.Key] = entry.Source + } + + if sources["KEY_A"] != tmpFileA || sources["KEY_B"] != tmpFileB { + t.Errorf("sources = %v, want KEY_A=%s and KEY_B=%s", sources, tmpFileA, tmpFileB) + } + }) + + t.Run("nil WithSummary is no-op", func(t *testing.T) { + type Config struct { + Value string `config:"VALUE"` + } + + t.Setenv("VALUE", "test") + + var cfg Config + if err := configutil.Set(&cfg, configutil.WithSummary(nil)); err != nil { + t.Fatalf("Set() error = %v", err) + } + + if cfg.Value != "test" { + t.Errorf("cfg.Value = %s, want test", cfg.Value) + } + }) + + t.Run("variable substitution", func(t *testing.T) { + type Config struct { + URL string `config:"URL"` + Host string `config:"HOST"` + } + + t.Setenv("HOST", "example.com") + t.Setenv("URL", "http://${HOST}:8080") + + var cfg Config + var summary configutil.LoadSummary + if err := configutil.Set(&cfg, configutil.WithSummary(&summary)); err != nil { + t.Fatalf("Set() error = %v", err) + } + + if len(summary.Entries) != 2 { + t.Fatalf("expected 2 entries, got %d", len(summary.Entries)) + } + + urlEntry := summary.Entries[0] + if urlEntry.Key != "URL" || urlEntry.Value != "http://example.com:8080" { + t.Errorf("URL entry = %+v, want Value=http://example.com:8080", urlEntry) + } + }) + + t.Run("multiple types", func(t *testing.T) { + type Config struct { + Str string `config:"STRING"` + Num int `config:"NUMBER"` + Float float64 `config:"FLOAT"` + Bool bool `config:"BOOLEAN"` + } + + t.Setenv("STRING", "text") + t.Setenv("NUMBER", "42") + t.Setenv("FLOAT", "3.14") + t.Setenv("BOOLEAN", "true") + + var cfg Config + var summary configutil.LoadSummary + if err := configutil.Set(&cfg, configutil.WithSummary(&summary)); err != nil { + t.Fatalf("Set() error = %v", err) + } + + if len(summary.Entries) != 4 { + t.Fatalf("expected 4 entries, got %d", len(summary.Entries)) + } + + for _, entry := range summary.Entries { + if entry.Source != "env" { + t.Errorf("entry %s has Source=%s, want env", entry.Key, entry.Source) + } + if entry.Value == "" { + t.Errorf("entry %s has empty Value", entry.Key) + } + } + }) +} diff --git a/populate.go b/populate.go index d10fafa..a3a0aaf 100644 --- a/populate.go +++ b/populate.go @@ -56,13 +56,29 @@ func (s *settings) handleField(field reflect.StructField, value reflect.Value, p return err } wasSet = true + if s.summary != nil { + s.summary.Entries = append(s.summary.Entries, LoadEntry{ + FieldName: field.Name, + Key: key, + Value: resolved, + Source: s.provenance[key], + }) + } } } if !wasSet && value.IsZero() && metadata.Default != "" { - if err := s.setFieldValue(value, entry{key: field.Name, value: metadata.Default, fieldName: field.Name}); err != nil { + if err := s.setFieldValue(value, entry{key: metadata.Name, value: metadata.Default, fieldName: field.Name}); err != nil { return &MalformedDefaultError{FieldName: field.Name, Default: metadata.Default, Err: err} } + if s.summary != nil { + s.summary.Entries = append(s.summary.Entries, LoadEntry{ + FieldName: field.Name, + Key: metadata.Name, + Value: metadata.Default, + Source: "default", + }) + } } if metadata.Required && !wasSet { diff --git a/settings.go b/settings.go index 3c76166..ce0a1d5 100644 --- a/settings.go +++ b/settings.go @@ -1,8 +1,10 @@ package configutil type settings struct { - source map[string]string - sources []source + source map[string]string + sources []source + provenance map[string]string + summary *LoadSummary } type entry struct { diff --git a/source.go b/source.go index a02ea92..221fc98 100644 --- a/source.go +++ b/source.go @@ -11,6 +11,7 @@ import ( // source provides configuration key-value pairs. type source interface { Load() (map[string]string, error) + Name() string } // flagSource loads values from command-line flags. @@ -28,6 +29,10 @@ func (flagSource) Load() (map[string]string, error) { return m, nil } +func (flagSource) Name() string { + return "flag" +} + // fileSource loads values from a .env file. type fileSource struct { filepath string @@ -40,6 +45,10 @@ func (s fileSource) Load() (map[string]string, error) { return parseEnvFile(s.filepath) } +func (s fileSource) Name() string { + return s.filepath +} + // parseEnvFile reads a .env file and returns its key-value pairs. func parseEnvFile(path string) (map[string]string, error) { file, err := os.Open(filepath.Clean(path)) @@ -110,3 +119,7 @@ func (environmentVariableSource) Load() (map[string]string, error) { } return m, nil } + +func (environmentVariableSource) Name() string { + return "env" +} diff --git a/summary.go b/summary.go new file mode 100644 index 0000000..3c98618 --- /dev/null +++ b/summary.go @@ -0,0 +1,17 @@ +package configutil + +// LoadEntry describes the value written to a single config field by [Set]. +type LoadEntry struct { + FieldName string // Go struct field name, e.g. "Port" + Key string // config key looked up, e.g. "SERVER_PORT" + Value string // resolved string value that was decoded into the field + Source string // "env", "flag", a file path from WithFilepath, or "default" +} + +// LoadSummary is populated when [WithSummary] is passed to [Set]. +// Entries contains one record for each field that received a value. +// Fields that were neither set from a source nor had a default are absent. +// LoadSummary.Entries is only meaningful when [Set] returns nil (no error). +type LoadSummary struct { + Entries []LoadEntry +}