package config import ( "encoding/json" "fmt" "log/slog" "maps" "reflect" "slices" "strings" "github.com/spf13/viper" "go.yaml.in/yaml/v3" ) // EnvPrefix prefixes environment variables that override configuration keys, // e.g. JOBSEARCH_LOGGING_LEVEL for logging.level. const EnvPrefix string = "JOBSEARCH" // EtcConfigPath is searched for config.* when no config file is given, before // the working directory. const EtcConfigPath string = "/etc/job-search" // JobSearchConfig is the application's whole configuration, one field per // section. Each field's cfg tag must match the section's JSCK* constant. type JobSearchConfig struct { Database JSCDatabase `cfg:"database"` Logging JSCLogging `cfg:"logging"` Server JSCServer `cfg:"server"` Sources JSCSources `cfg:"sources"` } // LoadDefaultConfig sets Viper's defaults from the configuration's default // struct tags. func LoadDefaultConfig() { var conf JobSearchConfig if err := applyDefaults(&conf); err != nil { panic(fmt.Sprintf("config: %v", err)) } LoadToViper(conf) } // InitDefaults returns the default configuration as dotted keys, as // Flatten gives it, for `config init` to write. Sections tagged // init:"hidden" (the test sources) are left out unless includeHidden is set. func InitDefaults(includeHidden bool) map[string]any { var conf JobSearchConfig if err := applyDefaults(&conf); err != nil { panic(fmt.Sprintf("config: %v", err)) } flat := conf.Flatten() if includeHidden { return flat } hidden := hiddenFromInitKeys("", reflect.TypeFor[JobSearchConfig]()) for _, section := range hidden { for key := range flat { if strings.HasPrefix(key, section+".") { delete(flat, key) } } } return flat } // ---------- Section methods (see Section) ---------- // Map implements Section. func (c JobSearchConfig) Map() map[string]any { return mapOf(c) } // Flatten implements Section. func (c JobSearchConfig) Flatten() map[string]any { return flattenOf("", c) } // TypeOf implements Section. func (c JobSearchConfig) TypeOf(key string) (reflect.Kind, error) { return typeOf(c, key) } // ValueOf implements Section. func (c JobSearchConfig) ValueOf(key string) (any, error) { return valueOf(c, key) } // SetValue implements Section. func (c *JobSearchConfig) SetValue(key string, value any) error { return setValue(c, key, value) } // LoadFromViper copies every key Viper knows that conf also has into conf. // Unknown keys are skipped; invalid values are logged and ignored. func LoadFromViper(conf *JobSearchConfig) { keys := viper.AllKeys() for i := range keys { key := keys[i] keyType, _ := conf.TypeOf(key) if keyType == reflect.Invalid { continue } value, ok := viperValue(key, keyType) if !ok { slog.Error( "unknown config key type", "key", key, "kind", keyType.String(), ) continue } if err := conf.SetValue(key, value); err != nil { slog.Warn("ignoring invalid config value", "key", key, "error", err) } } } // viperValue reads key from Viper as a value of kind. Its bool is false for a // kind LoadFromViper doesn't handle. func viperValue(key string, kind reflect.Kind) (any, bool) { switch kind { case reflect.String: return viper.GetString(key), true case reflect.Int: return viper.GetInt(key), true case reflect.Bool: return viper.GetBool(key), true case reflect.Slice: // A string, e.g. from an environment variable, is passed on for // SetValue to split on commas; GetStringSlice would split it on // spaces. A list of objects (a list of sections) is passed on as it // is, as GetStringSlice would flatten it. switch value := viper.Get(key).(type) { case string: return value, true case []map[string]any: // as LoadToViper sets it return value, true case []any: if len(value) > 0 { if _, ok := value[0].(map[string]any); ok { return value, true } } } return viper.GetStringSlice(key), true } return nil, false } // LoadToViper sets every key of conf as a Viper default. func LoadToViper(conf JobSearchConfig) { flatMap := conf.Flatten() for key := range flatMap { viper.SetDefault(key, flatMap[key]) } } // String formats the configuration one "key = value " line per key, with // dotted keys, sorted by key. func (c JobSearchConfig) String() string { var builder strings.Builder flat := c.Flatten() for _, key := range slices.Sorted(maps.Keys(flat)) { kind, _ := c.TypeOf(key) fmt.Fprint(&builder, key, " = ", flat[key], " <", kind, ">\n") } return builder.String() } // JSON formats the configuration as indented JSON. func (c JobSearchConfig) JSON() string { confMap := c.Map() encoded, _ := json.MarshalIndent(confMap, "", " ") return string(encoded) } // YAML formats the configuration as YAML. func (c JobSearchConfig) YAML() string { confMap := c.Map() encoded, _ := yaml.Marshal(confMap) return string(encoded) }