package config import ( "bytes" "encoding" "encoding/json" "errors" "fmt" apierrors "git.yuntuo.hk/k2safe/go-omni/errors" "math" "os" "reflect" "strconv" "strings" "time" "unicode" ) // EnvOptions controls typed project configuration overlays. Explicit env tags // are absolute names; otherwise JSON names become PREFIX_NESTED_FIELD names. // Empty is allow (default), ignore or reject. No input values appear in errors. type EnvOptions struct { Prefix string Empty string Lookup func(string) (string, bool) // Aliases maps canonical absolute environment names to ordered fallback names. // A present canonical value wins, including an explicit empty value. Aliases map[string][]string } // ApplyDefaults applies envDefault tags to zero fields before file overlays. func ApplyDefaults[T any](base T) (T, error) { return projectOverlay(base, EnvOptions{}, true) } // LoadProject applies code/tag defaults, a strict optional file, environment, // then the project's Validate method, before any logger or dependency is opened. // envDefault is only applied to zero-valued defaults before reading the file. func LoadProject[T any](path string, defaults T, options EnvOptions) (T, error) { var zero T cfg, err := projectOverlay(defaults, options, true) if err != nil { return zero, err } if path != "" { cfg, err = DecodeFile(path, cfg) if err != nil { return zero, errors.New("config: cannot decode file; check its path, format, field names and value types") } } cfg, err = OverlayEnv(cfg, options) if err != nil { return zero, err } if valid, ok := any(&cfg).(interface{ Validate() error }); ok { if err = valid.Validate(); err != nil { return zero, err } } return cfg, nil } // OverlayEnv overlays the entire project struct without mutating the input. // Tags: env:"CUSTOM_NAME,required,secret,empty=reject"; env:"-" excludes a // subtree. required means nonempty string/non-nil pointer or collection; false // and zero are valid scalars. Use project Validate for semantic constraints. // Slices/maps use JSON syntax, avoiding ambiguous comma and empty semantics. func OverlayEnv[T any](base T, options EnvOptions) (T, error) { return projectOverlay(base, options, false) } var textType = reflect.TypeOf((*encoding.TextUnmarshaler)(nil)).Elem() var durationType = reflect.TypeOf(time.Duration(0)) func projectOverlay[T any](base T, options EnvOptions, defaults bool) (T, error) { var zero T if options.Lookup == nil { options.Lookup = os.LookupEnv } if options.Empty == "" { options.Empty = "allow" } if options.Empty != "allow" && options.Empty != "ignore" && options.Empty != "reject" { return zero, errors.New("config: empty policy must be allow, ignore or reject") } v := reflect.ValueOf(&base).Elem() if v.Kind() != reflect.Struct { return zero, errors.New("config: project configuration must be a struct") } result := reflect.New(v.Type()).Elem() result.Set(v) seen := map[string]bool{} var walk func(reflect.Value, string, int) error walk = func(value reflect.Value, prefix string, depth int) error { if depth > 32 { return errors.New("config: nesting exceeds 32") } for i := 0; i < value.NumField(); i++ { field := value.Type().Field(i) dst := value.Field(i) if !dst.CanSet() || field.PkgPath != "" { continue } tag := strings.Split(field.Tag.Get("env"), ",") if tag[0] == "-" { continue } jsonName := strings.Split(field.Tag.Get("json"), ",")[0] if jsonName == "-" && tag[0] == "" { continue } if jsonName == "" { jsonName = field.Name } key := prefix + envName(jsonName) if tag[0] != "" { key = tag[0] } empty, required := options.Empty, false for _, opt := range tag[1:] { switch opt { case "required": required = true case "secret", "": case "empty=allow": empty = "allow" case "empty=ignore": empty = "ignore" case "empty=reject": empty = "reject" default: return fmt.Errorf("config: invalid env tag for %s", key) } } if dst.Kind() == reflect.Struct && !dst.Addr().Type().Implements(textType) { if err := walk(dst, key+"_", depth+1); err != nil { return err } continue } if dst.Kind() == reflect.Pointer && dst.Type().Elem().Kind() == reflect.Struct && !dst.Type().Implements(textType) { // Clone before traversing so a failed overlay never edits defaults. child := reflect.New(dst.Type().Elem()) if !dst.IsNil() { child.Elem().Set(dst.Elem()) } if err := walk(child.Elem(), key+"_", depth+1); err != nil { return err } if !dst.IsNil() || !child.Elem().IsZero() { dst.Set(child) } if required && !defaults && dst.IsNil() { return fmt.Errorf("config: %s is required", key) } continue } // Unexported runtime capabilities are skipped unless explicitly tagged. if dst.Kind() == reflect.Func || dst.Kind() == reflect.Chan || dst.Kind() == reflect.Interface { if tag[0] != "" { return fmt.Errorf("config: %s has unsupported environment type", key) } continue } if seen[key] { return fmt.Errorf("config: duplicate environment key %s", key) } seen[key] = true raw, present := options.Lookup(key) selectedKey := key if !present && !defaults { for _, alias := range options.Aliases[key] { if raw, present = options.Lookup(alias); present { selectedKey = alias break } } } if defaults { raw, present = field.Tag.Lookup("envDefault") present = present && dst.IsZero() } if present && raw == "" && !defaults { if empty == "reject" { return fmt.Errorf("config: %s must not be empty", selectedKey) } if empty == "ignore" { present = false } } if present { parsed := reflect.New(dst.Type()).Elem() if err := parseEnvValue(parsed, raw); err != nil { return fmt.Errorf("config: %s must be a valid %s", selectedKey, dst.Type()) } dst.Set(parsed) } if required && !defaults && emptyRequired(dst) { return fmt.Errorf("config: %s is required", key) } } return nil } if err := walk(result, options.Prefix, 0); err != nil { return zero, err } return result.Interface().(T), nil } func emptyRequired(v reflect.Value) bool { switch v.Kind() { case reflect.String: return v.Len() == 0 case reflect.Pointer, reflect.Map, reflect.Slice: return v.IsNil() } return false } func parseEnvValue(v reflect.Value, raw string) (err error) { defer func() { if recover() != nil { err = errors.New("invalid custom value") } }() if v.Kind() == reflect.Pointer { v.Set(reflect.New(v.Type().Elem())) return parseEnvValue(v.Elem(), raw) } if v.CanAddr() && v.Addr().Type().Implements(textType) { return v.Addr().Interface().(encoding.TextUnmarshaler).UnmarshalText([]byte(raw)) } if v.Type() == durationType { d, err := time.ParseDuration(raw) if err == nil { v.SetInt(int64(d)) } return err } switch v.Kind() { case reflect.String: v.SetString(raw) return nil case reflect.Bool: x, err := strconv.ParseBool(raw) if err == nil { v.SetBool(x) } return err case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: x, err := strconv.ParseInt(raw, 10, v.Type().Bits()) if err == nil { v.SetInt(x) } return err case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: x, err := strconv.ParseUint(raw, 10, v.Type().Bits()) if err == nil { v.SetUint(x) } return err case reflect.Float32, reflect.Float64: x, err := strconv.ParseFloat(raw, v.Type().Bits()) if err == nil && (math.IsNaN(x) || math.IsInf(x, 0)) { return errors.New("nonfinite value") } if err == nil { v.SetFloat(x) } return err case reflect.Slice, reflect.Map: var value any if err := apierrors.DecodeCatalogJSON([]byte(raw), &value); err != nil { return err } value, err := durations(value, v.Type()) if err != nil { return err } encoded, err := json.Marshal(value) if err != nil { return err } decoder := json.NewDecoder(bytes.NewReader(encoded)) decoder.DisallowUnknownFields() return decoder.Decode(v.Addr().Interface()) } return errors.New("unsupported environment value") } func envName(s string) string { r := []rune(s) var out strings.Builder for i, c := range r { if i > 0 && unicode.IsUpper(c) && (unicode.IsLower(r[i-1]) || unicode.IsDigit(r[i-1]) || (i+1 < len(r) && unicode.IsLower(r[i+1]))) { out.WriteByte('_') } if c == '-' { c = '_' } out.WriteRune(unicode.ToUpper(c)) } return out.String() }