From 803a8929e65acf3fff0718dafe298d3cf65f25f0 Mon Sep 17 00:00:00 2001 From: Craig Williams Date: Wed, 14 Oct 2026 15:38:38 +0100 Subject: [PATCH] printLoadedConf modified to only print explicitly set config values (via env or config file) --- internal/config/debug.go | 16 ++++--- internal/config/load.go | 25 +++++++++- internal/config/load_test.go | 88 ++++++++++++++++++++++++++++++++++++ 3 files changed, 122 insertions(+), 7 deletions(-) diff --git a/internal/config/debug.go b/internal/config/debug.go index 8fd269d..62fc643 100644 --- a/internal/config/debug.go +++ b/internal/config/debug.go @@ -24,17 +24,21 @@ func printEnv() { } } -// printLoadedConfig logs every key Viper has loaded, with its value and type, -// at debug level. +// printLoadedConfig logs every key set by the config file or an environment +// variable (see isExplicitlySet), with its value and type, at debug level. func printLoadedConfig() { slog.Debug("Loaded config:") keys := viper.AllKeys() for i := range keys { k := keys[i] - v := viper.Get(k) - t := reflect.TypeOf(v) - slog.Debug( - "\t" + k + " = " + fmt.Sprintf("%#v", v) + " <" + t.Kind().String() + ">") + if isExplicitlySet(k) { + v := viper.Get(k) + t := reflect.TypeOf(v) + slog.Debug( + "\t" + k + " = " + fmt.Sprintf("%#v", v) + + " <" + t.Kind().String() + ">", + ) + } } } diff --git a/internal/config/load.go b/internal/config/load.go index 4d3005f..0e133fb 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -3,12 +3,35 @@ package config import ( "fmt" "log/slog" + "os" "strings" "github.com/joho/godotenv" "github.com/spf13/viper" ) +// envKeyReplacer turns a dotted key into its environment variable's suffix +// (logging.level to LOGGING_LEVEL, once upper-cased). +var envKeyReplacer = strings.NewReplacer(".", "_") + +// envVarName returns the environment variable that sets key, e.g. +// JOBSEARCH_LOGGING_LEVEL for logging.level. +func envVarName(key string) string { + return EnvPrefix + "_" + strings.ToUpper(envKeyReplacer.Replace(key)) +} + +// isExplicitlySet reports whether key's value in Viper was set by the config +// file or by its environment variable, rather than coming from its default. +// As Viper does, it ignores an empty environment variable. Values set with +// viper.Set (e.g. by StoreValue) are not counted. +func isExplicitlySet(key string) bool { + if viper.InConfig(key) { + return true + } + value, ok := os.LookupEnv(envVarName(key)) + return ok && value != "" +} + // loadEnv loads environment variables from a .env file in the working // directory. func loadEnv() error { @@ -37,7 +60,7 @@ func loadConfig(cfgFile string) error { LoadDefaultConfig() viper.SetEnvPrefix(EnvPrefix) - viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) + viper.SetEnvKeyReplacer(envKeyReplacer) viper.AutomaticEnv() // read in environment variables that match err := viper.ReadInConfig() diff --git a/internal/config/load_test.go b/internal/config/load_test.go index 86761f1..8007b7d 100644 --- a/internal/config/load_test.go +++ b/internal/config/load_test.go @@ -7,6 +7,8 @@ import ( "strings" "testing" "time" + + "github.com/spf13/viper" ) // initDir makes a temp working directory holding a .env file (if env is not @@ -167,3 +169,89 @@ func TestInitEnvOverride(t *testing.T) { t.Errorf("FTSMock.Interval = %v; want 45s from the environment", conf.Sources.FTSMock.Interval) } } + +func TestEnvVarName(t *testing.T) { + tests := map[string]string{ + "logging.level": "JOBSEARCH_LOGGING_LEVEL", + "sources.fts_mock.interval": "JOBSEARCH_SOURCES_FTS_MOCK_INTERVAL", + "server.websocket.connection_limit.enabled": "JOBSEARCH_SERVER_WEBSOCKET_CONNECTION_LIMIT_ENABLED", + } + for key, want := range tests { + if got := envVarName(key); got != want { + t.Errorf("envVarName(%q) = %q; want %q", key, got, want) + } + } +} + +// explicitConfigYAML sets a top-level and a nested key in a config file. +const explicitConfigYAML = `logging: + level: info +server: + websocket: + connection_limit: + enabled: true +` + +// loadExplicit sets up Viper as Init does, with the defaults, the config +// file explicitConfigYAML and the JOBSEARCH_* environment variables. +func loadExplicit(t *testing.T) { + t.Helper() + resetViper(t) + LoadDefaultConfig() + viper.SetEnvPrefix(EnvPrefix) + viper.SetEnvKeyReplacer(envKeyReplacer) + viper.AutomaticEnv() + viper.SetConfigType("yaml") + if err := viper.ReadConfig(strings.NewReader(explicitConfigYAML)); err != nil { + t.Fatal(err) + } +} + +// A key is explicitly set by the config file or a non-empty environment +// variable, not by its default or viper.Set. +func TestIsExplicitlySet(t *testing.T) { + t.Setenv("JOBSEARCH_DATABASE_FILE", "/tmp/js.db") + t.Setenv("JOBSEARCH_LOGGING_FILE", "") + loadExplicit(t) + viper.Set("sources.sleeper.enabled", true) + + tests := map[string]bool{ + "logging.level": true, // config file + "server.websocket.connection_limit.enabled": true, // nested, config file + "database.file": true, // environment + "logging.file": false, // empty environment variable + "sources.sleeper.interval": false, // default only + "sources.sleeper.enabled": false, // viper.Set + "server.websocket.connection_limit.burst": false, // default, beside a set key + } + for key, want := range tests { + if got := isExplicitlySet(key); got != want { + t.Errorf("isExplicitlySet(%q) = %v; want %v", key, got, want) + } + } +} + +// printLoadedConfig logs only the keys set by the config file or the +// environment. +func TestPrintLoadedConfig(t *testing.T) { + t.Setenv("JOBSEARCH_DATABASE_FILE", "/tmp/js.db") + loadExplicit(t) + logs := captureLogs(t, slog.LevelDebug) + printLoadedConfig() + + out := logs.String() + for _, want := range []string{ + `logging.level = \"info\" `, + `server.websocket.connection_limit.enabled = true `, + `database.file = \"/tmp/js.db\" `, + } { + if !strings.Contains(out, want) { + t.Errorf("missing %q in:\n%s", want, out) + } + } + for _, unwanted := range []string{"sources.", "logging.file", "connection_limit.burst"} { + if strings.Contains(out, unwanted) { + t.Errorf("logged default key %q:\n%s", unwanted, out) + } + } +}