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 +}