// Package config provides generic YAML config loading with env override support. package config import ( "fmt" "os" "reflect" "strconv" "strings" "gopkg.in/yaml.v3" ) // LoadYAML reads a YAML file at path and unmarshals it into T. // If path is empty or the file does not exist and optional is true, the zero value of T is returned. func LoadYAML[T any](path string, optional bool) (T, error) { var zero T if path == "" { if optional { return zero, nil } return zero, fmt.Errorf("config path is empty") } data, err := os.ReadFile(path) if err != nil { if optional && os.IsNotExist(err) { return zero, nil } return zero, fmt.Errorf("reading config file %q: %w", path, err) } var cfg T if err := yaml.Unmarshal(data, &cfg); err != nil { return zero, fmt.Errorf("parsing config file %q: %w", path, err) } return cfg, nil } // OverrideFromEnv applies environment variable overrides to a struct using struct tags. // Tag format: env:"ENV_VAR_NAME" // Supports field types: string, int, bool, []string (comma-separated). // envPrefix is prepended to tag values if non-empty (e.g. envPrefix="APP" + tag="PORT" → "APP_PORT"). func OverrideFromEnv[T any](cfg *T, envPrefix string) error { return overrideStruct(reflect.ValueOf(cfg).Elem(), envPrefix) } func overrideStruct(v reflect.Value, envPrefix string) error { t := v.Type() for i := 0; i < t.NumField(); i++ { field := t.Field(i) fv := v.Field(i) if !fv.CanSet() { continue } // Recurse into embedded/nested structs if fv.Kind() == reflect.Struct { if err := overrideStruct(fv, envPrefix); err != nil { return err } continue } envTag := field.Tag.Get("env") if envTag == "" { continue } envKey := envTag if envPrefix != "" { envKey = envPrefix + "_" + envTag } val := os.Getenv(envKey) if val == "" { continue } switch fv.Kind() { case reflect.String: fv.SetString(val) case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: n, err := strconv.ParseInt(val, 10, 64) if err != nil { return fmt.Errorf("env %s: cannot parse %q as int: %w", envKey, val, err) } fv.SetInt(n) case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: n, err := strconv.ParseUint(val, 10, 64) if err != nil { return fmt.Errorf("env %s: cannot parse %q as uint: %w", envKey, val, err) } fv.SetUint(n) case reflect.Bool: b, err := strconv.ParseBool(val) if err != nil { return fmt.Errorf("env %s: cannot parse %q as bool: %w", envKey, val, err) } fv.SetBool(b) case reflect.Slice: if fv.Type().Elem().Kind() == reflect.String { parts := strings.Split(val, ",") for i, p := range parts { parts[i] = strings.TrimSpace(p) } fv.Set(reflect.ValueOf(parts)) } } } return nil }