aboutsummaryrefslogtreecommitdiff
path: root/go/conf
diff options
context:
space:
mode:
authortsne <tsne.dev@outlook.com>2026-09-05 13:05:44 +0200
committertsne <tsne.dev@outlook.com>2026-09-05 13:05:44 +0200
commit61f593278a49db6d07efcd4b20234f992fc747e3 (patch)
tree5b1a1bf70497c402942fec12e813c9eaad53d0bb /go/conf
parentzig: add log (diff)
downloadmulibs-61f593278a49db6d07efcd4b20234f992fc747e3.tar.gz
go: add confHEADmain
Diffstat (limited to 'go/conf')
-rw-r--r--go/conf/conf.go273
1 files changed, 273 insertions, 0 deletions
diff --git a/go/conf/conf.go b/go/conf/conf.go
new file mode 100644
index 0000000..631a327
--- /dev/null
+++ b/go/conf/conf.go
@@ -0,0 +1,273 @@
+// Read configuration values from files and environment variables.
+// A configuration file has the following format:
+//
+// # a comment always starts at the beginning of a line
+// key = value
+//
+// Keys must only contain alpha-numeric characters, dots (`.`), and
+// underscores (`_`). All leading and trailing spaces from keys and
+// values are trimmed. Configuration values can be of type bool, integer,
+// float, string, or string slice with configurable delimiter.
+//
+// All configuration values are either read from a file or from the environment.
+// An environment variable overwrites the configuration value of a file. The
+// name of the environment variable is derived from the configuration key by
+// replacing all dots with underscores, transforming everything to uppercase, and
+// prepending an optional prefix. For example the environment variable for a key
+// `foo.bar_baz` is `FOO_BAR_BAZ`.
+//
+// Package API:
+//
+// `Read` - read a configuration into a struct
+// `FromFile` - option to define a configuration file
+// `EnvPrefix` - option to define a prefix for environment variable
+// `ListDelimiter` - option to define a delimiter for slice values
+package conf
+
+import (
+ "bytes"
+ "errors"
+ "os"
+ "reflect"
+ "strconv"
+ "strings"
+)
+
+const configTag = "conf"
+
+// Read reads the configuration values specified by `config`. The given
+// configuration type must be a struct where each field must define its key
+// in a `conf` tag.
+//
+// A non-existing configuration file is equivalent to an empty file and does
+// not return an error. If a value is neither defined as an environment variable
+// nor in the configuration file, the struct field will not be touched.
+func Read[T any](config *T, opts ...Option) error {
+ o := options{listDelim: ","}
+ for _, opt := range opts {
+ opt(&o)
+ }
+
+ var kvs []kv
+ if o.filename != "" {
+ kvfile, err := os.ReadFile(o.filename)
+ if err != nil {
+ if !errors.Is(err, os.ErrNotExist) {
+ return err
+ }
+ } else {
+ kvs, err = parseKV(kvfile)
+ if err != nil {
+ return err
+ }
+ }
+ }
+
+ structVal := reflect.ValueOf(config).Elem()
+ if structVal.Kind() != reflect.Struct {
+ panic("config type must be a struct")
+ }
+ return assignStructFields(structVal, environment(kvs, o.envPrefix), o)
+}
+
+type options struct {
+ filename string
+ envPrefix string
+ listDelim string
+}
+
+type Option func(*options)
+
+// FromFile specifies a file where configuration values are read from.
+func FromFile(filename string) Option {
+ return func(o *options) { o.filename = filename }
+}
+
+// EnvPrefix defines a prefix that will be preprended to the configuration key when
+// consulting the environment.
+func EnvPrefix(prefix string) Option {
+ return func(o *options) { o.envPrefix = prefix }
+}
+
+// ListDelimiter defines the delimiter by which string slices are split. The given
+// value must not be empty.
+func ListDelimiter(delim string) Option {
+ if delim == "" {
+ panic("list delimiter must not be empty")
+ }
+ return func(o *options) { o.listDelim = delim }
+}
+
+func assignStructFields(structVal reflect.Value, env env, opts options) error {
+ for _, field := range reflect.VisibleFields(structVal.Type()) {
+ fieldVal := structVal.FieldByIndex(field.Index)
+ switch fieldVal.Kind() {
+ case reflect.Struct:
+ err := assignStructFields(fieldVal, env, opts)
+ if err != nil {
+ return err
+ }
+
+ case reflect.Slice:
+ key := keyOf(field)
+ if val, has := env.get(key); has {
+ elems := strings.Split(val, opts.listDelim)
+ sliceVals := reflect.MakeSlice(fieldVal.Type(), len(elems), len(elems))
+ for i, elem := range elems {
+ err := assignScalarField(sliceVals.Index(i), key, elem)
+ if err != nil {
+ return err
+ }
+ }
+ fieldVal.Set(sliceVals)
+ }
+
+ default:
+ key := keyOf(field)
+ if val, has := env.get(key); has {
+ err := assignScalarField(fieldVal, key, val)
+ if err != nil {
+ return err
+ }
+ }
+ }
+ }
+ return nil
+}
+
+func assignScalarField(field reflect.Value, key string, val string) error {
+ errInvalid := func() error {
+ return errors.New("invalid config value " + key + ": '" + val + "'")
+ }
+
+ switch field.Kind() {
+ case reflect.Bool:
+ switch strings.ToLower(val) {
+ case "yes", "true", "1":
+ field.SetBool(true)
+ case "no", "false", "0":
+ field.SetBool(false)
+ default:
+ return errInvalid()
+ }
+
+ case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
+ v, err := strconv.ParseInt(val, 10, field.Type().Bits())
+ if err != nil {
+ return errInvalid()
+ }
+ field.SetInt(v)
+
+ case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
+ v, err := strconv.ParseUint(val, 10, field.Type().Bits())
+ if err != nil {
+ return errInvalid()
+ }
+ field.SetUint(v)
+
+ case reflect.Float32, reflect.Float64:
+ val, err := strconv.ParseFloat(val, field.Type().Bits())
+ if err != nil {
+ return errInvalid()
+ }
+ field.SetFloat(val)
+
+ case reflect.String:
+ v := val
+ if len(v) > 0 && (v[0] == '"' || v[0] == '\'') {
+ var err error
+ v, err = strconv.Unquote(val)
+ if err != nil {
+ return errInvalid()
+ }
+ }
+ field.SetString(v)
+
+ default:
+ panic("unsupported field type " + field.Type().Name())
+ }
+
+ return nil
+}
+
+type kv struct {
+ key string
+ val string
+}
+
+func parseKV(content []byte) ([]kv, error) {
+ var kvs []kv
+ for ln := 1; len(content) > 0; ln++ {
+ eol := bytes.IndexByte(content, '\n')
+ if eol < 0 {
+ eol = len(content) - 1
+ }
+
+ line := bytes.TrimSpace(content[:eol+1])
+ content = content[eol+1:]
+ if len(line) == 0 || line[0] == '#' {
+ continue
+ }
+
+ eq := bytes.IndexByte(line, '=')
+ if eq < 0 {
+ return nil, errors.New("invalid config format (line " + strconv.Itoa(ln) + ")")
+ }
+
+ kvs = append(kvs, kv{
+ key: string(bytes.TrimSpace(line[:eq])),
+ val: string(bytes.TrimSpace(line[eq+1:])),
+ })
+ }
+ return kvs, nil
+}
+
+func keyOf(f reflect.StructField) string {
+ k := f.Tag.Get(configTag)
+ if k == "" {
+ panic("missing " + configTag + " for field " + f.Name)
+ }
+ for _, r := range k {
+ switch {
+ case 'a' <= r && r <= 'z':
+ case 'A' <= r && r <= 'Z':
+ case '0' <= r && r <= '9':
+ case r == '.' || r == '_':
+ default:
+ panic("unsupported config key " + k)
+ }
+ }
+ return k
+}
+
+type env struct {
+ kvs []kv
+ envPrefix string
+}
+
+func environment(kvs []kv, envPrefix string) env {
+ return env{
+ kvs: kvs,
+ envPrefix: envPrefix,
+ }
+}
+
+func (env env) get(key string) (string, bool) {
+ envkey := key
+ envkey = env.envPrefix + envkey
+ envkey = strings.ReplaceAll(envkey, ".", "_")
+ envkey = strings.ToUpper(envkey)
+ if val, has := os.LookupEnv(envkey); has {
+ return val, true
+ }
+
+ // When a key exists twice, the second occurence should override
+ // the first one. So we need to search backwards here.
+ for i := len(env.kvs); i > 0; {
+ i--
+ if kv := env.kvs[i]; kv.key == key {
+ return kv.val, true
+ }
+ }
+ return "", false
+}