diff --git a/internal/config/conf_logging.go b/internal/config/conf_logging.go index 98fb55e..119c826 100644 --- a/internal/config/conf_logging.go +++ b/internal/config/conf_logging.go @@ -22,76 +22,48 @@ import ( "reflect" ) +const JSCKLogging string = "logging" + type JobSearchConfigLogging struct { - Level string - File string + Level string `cfg:"level" default:"warn"` + File string `cfg:"file"` } -// Default configuration -func defaultConfigLogging() JobSearchConfigLogging { - return JobSearchConfigLogging{ - Level: "warn", - } +/* + * General section functions + * See related methods in section.go + */ +func (c *JobSearchConfigLogging) Keys() []string { return keysOf(c) } + +func (c JobSearchConfigLogging) Map() map[string]any { return mapOf(c) } + +func (c JobSearchConfigLogging) Flatten() map[string]any { + return flattenOf(JSCKLogging, c) } -// Converts configuration to a map -func (conf JobSearchConfigLogging) Map() map[string]any { - return map[string]any{ - "level": conf.Level, - "file": conf.File, - } -} - -// Converts configuration to a flat dotted format -func (conf JobSearchConfigLogging) Flatten() map[string]any { - confMap := conf.Map() - retMap := map[string]any{} - for k, v := range confMap { - retMap["logging."+k] = v - } - return retMap -} - -// Returns the expected type of a configuration key value func (c JobSearchConfigLogging) TypeOf(key string) (reflect.Kind, error) { - switch key { - case "level": - return reflect.String, nil - case "file": - return reflect.String, nil - default: - return reflect.Invalid, errors.New("unknown configuration key") - } + return typeOf(c, key) } -// Returns the value of a configuration key func (c JobSearchConfigLogging) ValueOf(key string) (any, error) { - switch key { - case "level": - return c.Level, nil - case "file": - return c.File, nil - default: - return "", errors.New("unknown configuration key") - } + return valueOf(c, key) } -// Sets the value of a configuration key func (c *JobSearchConfigLogging) SetValue(key string, value any) error { + return setValue(c, key, value) +} + +/* Section specific functions */ +// Checks values before they are stored (see validator) +func (c *JobSearchConfigLogging) validate(key string, value any) error { switch key { case "level": - newLevel := value.(string) - switch newLevel { + switch value.(string) { case "debug", "info", "warn", "error": - c.Level = value.(string) default: return errors.New( "invalid log level. Must be one of: debug, info, warn, error") } - case "file": - c.File = value.(string) - default: - return errors.New("unknown configuration key") } return nil } diff --git a/internal/config/config.go b/internal/config/config.go index b28c817..5123a72 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -18,9 +18,8 @@ package config import ( "encoding/json" - "errors" "fmt" - "maps" + "log/slog" "reflect" "strings" @@ -31,65 +30,37 @@ import ( const EnvPrefix string = "JOBSEARCH" const EtcConfigPath string = "/etc/job-search" +// Each field's cfg tag must match the section's JSCK* constant. type JobSearchConfig struct { - Logging JobSearchConfigLogging + Logging JobSearchConfigLogging `cfg:"logging"` } -// Default configuration +// Default configuration, taken from the default struct tags func LoadDefaultConfig() { - conf := JobSearchConfig{ - Logging: defaultConfigLogging(), + var conf JobSearchConfig + if err := applyDefaults(&conf); err != nil { + panic(fmt.Sprintf("config: %v", err)) } LoadToViper(conf) } -// Converts configuration to a map -func (conf JobSearchConfig) Map() map[string]any { - confMap := map[string]any{} - confMap["logging"] = conf.Logging.Map() - return confMap -} +func (c JobSearchConfig) Map() map[string]any { return mapOf(c) } -// Converts configuration to a flat dotted format -func (conf JobSearchConfig) Flatten() map[string]any { - confFlat := map[string]any{} - maps.Copy(confFlat, conf.Logging.Flatten()) - return confFlat -} +func (c JobSearchConfig) Flatten() map[string]any { return flattenOf("", c) } -// Returns the expected type of a configuration key value func (c JobSearchConfig) TypeOf(key string) (reflect.Kind, error) { - keyParts := strings.Split(key, ".") - switch keyParts[0] { - case "logging": - return c.Logging.TypeOf(strings.Join(keyParts[1:], ".")) - default: - return reflect.Invalid, errors.New("unknown configuration key") - } + return typeOf(c, key) } -// Returns the expected type of a configuration value func (c JobSearchConfig) ValueOf(key string) (any, error) { - keyParts := strings.Split(key, ".") - switch keyParts[0] { - case "logging": - return c.Logging.ValueOf(strings.Join(keyParts[1:], ".")) - default: - return nil, errors.New("unknown configuration key") - } + return valueOf(c, key) } -// Returns the expected type of a configuration value func (c *JobSearchConfig) SetValue(key string, value any) error { - keyParts := strings.Split(key, ".") - switch keyParts[0] { - case "logging": - return c.Logging.SetValue(strings.Join(keyParts[1:], "."), value) - default: - return errors.New("unknown configuration key") - } + return setValue(c, key, value) } +// Utility method for loading a configuration from Viper func LoadFromViper(conf *JobSearchConfig) { keys := viper.AllKeys() for idx := range keys { @@ -105,10 +76,13 @@ func LoadFromViper(conf *JobSearchConfig) { conf.SetValue(key, viper.GetInt(key)) case reflect.Bool: conf.SetValue(key, viper.GetBool(key)) + default: + slog.Error(fmt.Sprintf("unknown key type: %s", keyType)) } } } +// Utility method for loading a configuration into Viper as defaults func LoadToViper(conf JobSearchConfig) { flatMap := conf.Flatten() for key := range flatMap { @@ -121,7 +95,7 @@ func (conf JobSearchConfig) String() string { var sb strings.Builder for k, v := range conf.Flatten() { typ, _ := conf.TypeOf(k) - sb.WriteString(fmt.Sprint(k, " = ", v, " <", typ, ">")) + sb.WriteString(fmt.Sprint(k, " = ", v, " <", typ, ">\n")) } return sb.String() } diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..e04eb29 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,84 @@ +/* +Copyright © 2026 Craig Williams + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Lesser General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public License +along with this program. If not, see . +*/ +package config + +import ( + "reflect" + "testing" +) + +// Kinds that LoadFromViper knows how to read. +var viperKinds = map[reflect.Kind]bool{ + reflect.String: true, reflect.Int: true, reflect.Bool: true, +} + +// Every key round-trips through TypeOf/ValueOf/SetValue and has a type that +// LoadFromViper can read. +func TestKeysRoundTrip(t *testing.T) { + var conf JobSearchConfig + if err := applyDefaults(&conf); err != nil { + t.Fatalf("defaults: %v", err) + } + for key, want := range conf.Flatten() { + kind, err := conf.TypeOf(key) + if err != nil { + t.Errorf("TypeOf(%q): %v", key, err) + } else if !viperKinds[kind] { + t.Errorf("TypeOf(%q) = %v, which LoadFromViper does not handle", key, kind) + } + got, err := conf.ValueOf(key) + if err != nil || !reflect.DeepEqual(got, want) { + t.Errorf("ValueOf(%q) = %#v, %v; want %#v", key, got, err, want) + } + if err := conf.SetValue(key, got); err != nil { + t.Errorf("SetValue(%q, %#v): %v", key, got, err) + } + } +} + +// Every section has the full method set, and its Flatten keys (which use the +// section's JSCK* constant) line up with its cfg tag in the parent. +func TestSections(t *testing.T) { + var conf JobSearchConfig + all := conf.Flatten() + checkSections(t, all, "", reflect.ValueOf(&conf).Elem()) +} + +func checkSections(t *testing.T, all map[string]any, prefix string, section reflect.Value) { + for _, f := range fieldsOf(section.Type()) { + if !f.section { + continue + } + fv := section.FieldByIndex(f.index) + name := fv.Type().Name() + for _, m := range []string{"Map", "Flatten", "TypeOf", "ValueOf", "SetValue"} { + if !fv.Addr().MethodByName(m).IsValid() { + t.Errorf("%s is missing method %s", name, m) + } + } + if flatten := fv.MethodByName("Flatten"); flatten.IsValid() { + keys := flatten.Call(nil)[0].Interface().(map[string]any) + for k := range keys { + if _, ok := all[joinKey(prefix, k)]; !ok { + t.Errorf("%s.Flatten() key %q does not match its cfg tag %q", + name, k, joinKey(prefix, f.key)) + } + } + } + checkSections(t, all, joinKey(prefix, f.key), fv) + } +} diff --git a/internal/config/section.go b/internal/config/section.go new file mode 100644 index 0000000..973d229 --- /dev/null +++ b/internal/config/section.go @@ -0,0 +1,314 @@ +/* +Copyright © 2026 Craig Williams + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Lesser General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public License +along with this program. If not, see . +*/ +package config + +import ( + "errors" + "fmt" + "reflect" + "strconv" + "strings" + "time" +) + +// Configuration sections are plain structs described with struct tags: +// +// cfg:"key" the field's configuration key. Fields without it are +// not part of the configuration. +// default:"value" the field's default, parsed the same way as a string +// passed to SetValue (e.g. "10m", "true", "42"). +// +// A tagged field whose type is a struct (other than time.Time) is a nested +// section, addressed as "section.key". An embedded struct has its tagged +// fields promoted into the enclosing section, just as Go promotes fields. +// +// time.Duration fields report reflect.String from TypeOf and accept either a +// time.Duration or a duration string such as "90s" in SetValue. +// +// A section can check values before they are stored by implementing +// validator on its pointer type. +const ( + keyTag = "cfg" + defaultTag = "default" +) + +var ( + errUnknownKey = errors.New("unknown configuration key") + durationType = reflect.TypeOf(time.Duration(0)) + timeType = reflect.TypeOf(time.Time{}) +) + +// validator is implemented by sections that need to reject some values. +// key is the field's own key within the section; value has already been +// converted to the field's type. +type validator interface { + validate(key string, value any) error +} + +type field struct { + key string + index []int // path for reflect.Value.FieldByIndex + typ reflect.Type + section bool + def string + hasDefault bool +} + +// fieldsOf lists the configuration fields of a section type in declaration +// order, with the fields of embedded structs promoted into it. +func fieldsOf(t reflect.Type) []field { + var fields []field + for i := 0; i < t.NumField(); i++ { + sf := t.Field(i) + if sf.Anonymous && sf.Type.Kind() == reflect.Struct { + for _, f := range fieldsOf(sf.Type) { + f.index = append([]int{i}, f.index...) + fields = append(fields, f) + } + continue + } + key, ok := sf.Tag.Lookup(keyTag) + if !ok || key == "" || key == "-" || !sf.IsExported() { + continue + } + def, hasDefault := sf.Tag.Lookup(defaultTag) + fields = append(fields, field{ + key: key, + index: []int{i}, + typ: sf.Type, + section: sf.Type.Kind() == reflect.Struct && sf.Type != timeType, + def: def, + hasDefault: hasDefault, + }) + } + return fields +} + +// structValue dereferences v down to the section struct it holds. +func structValue(v any) reflect.Value { + rv := reflect.ValueOf(v) + for rv.Kind() == reflect.Pointer { + rv = rv.Elem() + } + if rv.Kind() != reflect.Struct { + panic(fmt.Sprintf("config: %T is not a configuration section", v)) + } + return rv +} + +// lookup resolves a dotted key to the leaf field and the section holding it. +func lookup(section reflect.Value, key string) (reflect.Value, field, error) { + head, rest, nested := strings.Cut(key, ".") + for _, f := range fieldsOf(section.Type()) { + if f.key != head { + continue + } + switch { + case f.section && nested: + return lookup(section.FieldByIndex(f.index), rest) + case !f.section && !nested: + return section, f, nil + } + // Key matched but the shape didn't: a section with no sub-key, or a + // value with one. + return reflect.Value{}, field{}, errUnknownKey + } + return reflect.Value{}, field{}, errUnknownKey +} + +func joinKey(prefix, key string) string { + if prefix == "" { + return key + } + return prefix + "." + key +} + +// keysOf returns the keys directly in a section. +func keysOf(v any) []string { + fields := fieldsOf(structValue(v).Type()) + keys := make([]string, 0, len(fields)) + for _, f := range fields { + keys = append(keys, f.key) + } + return keys +} + +// mapOf converts a section to a map, with nested sections as nested maps. +func mapOf(v any) map[string]any { + return mapOfValue(structValue(v)) +} + +func mapOfValue(section reflect.Value) map[string]any { + m := map[string]any{} + for _, f := range fieldsOf(section.Type()) { + fv := section.FieldByIndex(f.index) + if f.section { + m[f.key] = mapOfValue(fv) + } else { + m[f.key] = fv.Interface() + } + } + return m +} + +// flattenOf converts a section to a flat map of dotted keys, each prefixed +// with prefix (if non-empty). +func flattenOf(prefix string, v any) map[string]any { + m := map[string]any{} + flattenInto(m, prefix, structValue(v)) + return m +} + +func flattenInto(m map[string]any, prefix string, section reflect.Value) { + for _, f := range fieldsOf(section.Type()) { + fv := section.FieldByIndex(f.index) + key := joinKey(prefix, f.key) + if f.section { + flattenInto(m, key, fv) + } else { + m[key] = fv.Interface() + } + } +} + +// kindOf is the kind reported by TypeOf, i.e. how the value is read from +// Viper. Durations are read as strings and parsed by SetValue. +func kindOf(t reflect.Type) reflect.Kind { + if t == durationType { + return reflect.String + } + return t.Kind() +} + +func typeOf(v any, key string) (reflect.Kind, error) { + _, f, err := lookup(structValue(v), key) + if err != nil { + return reflect.Invalid, err + } + return kindOf(f.typ), nil +} + +func valueOf(v any, key string) (any, error) { + section, f, err := lookup(structValue(v), key) + if err != nil { + return nil, err + } + return section.FieldByIndex(f.index).Interface(), nil +} + +// setValue sets a dotted key on the section that ptr points to. +func setValue(ptr any, key string, value any) error { + section, f, err := lookup(structValue(ptr), key) + if err != nil { + return err + } + return setField(section, f, value) +} + +func setField(section reflect.Value, f field, value any) error { + newValue, err := convert(value, f.typ) + if err != nil { + return err + } + if v, ok := section.Addr().Interface().(validator); ok { + if err := v.validate(f.key, newValue.Interface()); err != nil { + return err + } + } + section.FieldByIndex(f.index).Set(newValue) + return nil +} + +// convert turns value into a value of type t. Values already of the right +// type are used as-is; strings are parsed. +func convert(value any, t reflect.Type) (reflect.Value, error) { + if value != nil { + rv := reflect.ValueOf(value) + if rv.Type().AssignableTo(t) { + return rv, nil + } + if s, ok := value.(string); ok { + return parseString(s, t) + } + } + return reflect.Value{}, fmt.Errorf("unsupported type %T", value) +} + +func parseString(s string, t reflect.Type) (reflect.Value, error) { + v := reflect.New(t).Elem() + if t == durationType { + d, err := time.ParseDuration(s) + if err != nil { + return reflect.Value{}, fmt.Errorf("unable to convert to duration: %w", err) + } + v.SetInt(int64(d)) + return v, nil + } + + var err error + switch t.Kind() { + case reflect.String: + v.SetString(s) + case reflect.Bool: + var b bool + b, err = strconv.ParseBool(s) + v.SetBool(b) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + var n int64 + n, err = strconv.ParseInt(s, 10, t.Bits()) + v.SetInt(n) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + var n uint64 + n, err = strconv.ParseUint(s, 10, t.Bits()) + v.SetUint(n) + case reflect.Float32, reflect.Float64: + var n float64 + n, err = strconv.ParseFloat(s, t.Bits()) + v.SetFloat(n) + default: + return reflect.Value{}, fmt.Errorf("unsupported type string for %s", t) + } + if err != nil { + return reflect.Value{}, fmt.Errorf("unable to convert %q to %s: %w", s, t, err) + } + return v, nil +} + +// applyDefaults sets every field that has a default tag, recursing into +// nested sections. ptr must point to a section struct. +func applyDefaults(ptr any) error { + return applyDefaultsTo("", structValue(ptr)) +} + +func applyDefaultsTo(prefix string, section reflect.Value) error { + for _, f := range fieldsOf(section.Type()) { + key := joinKey(prefix, f.key) + if f.section { + if err := applyDefaultsTo(key, section.FieldByIndex(f.index)); err != nil { + return err + } + continue + } + if !f.hasDefault { + continue + } + if err := setField(section, f, f.def); err != nil { + return fmt.Errorf("invalid default for %s: %w", key, err) + } + } + return nil +}