package config import ( "encoding" "errors" "math" "reflect" "strconv" "strings" "time" ) // LoadProject clones defaults, resolves only explicit file references, binds the // provided fields and validates. Empty path uses only defaults. No env tags, // prefixes, aliases or implicit environment overlays are applied. func LoadProject[T any](path string, defaults T, loaders ...*Loader) (T, error) { var zero T loader, err := selectLoader(loaders) if err != nil { return zero, err } if err = rejectEnvironmentTags(reflect.TypeOf(defaults), map[reflect.Type]bool{}); err != nil { return zero, err } doc := Document{Value: map[string]any{}} if path != "" { doc, err = loader.read(path) if err != nil { return zero, err } } cfg, err := bindDocument(doc, defaults) if err != nil { return zero, locateError(err, path, doc) } if valid, ok := any(&cfg).(interface{ Validate() error }); ok { if err = valid.Validate(); err != nil { return zero, locateError(err, path, doc) } } else if valid, ok := any(cfg).(interface{ Validate() error }); ok { if err = valid.Validate(); err != nil { return zero, locateError(err, path, doc) } } return cfg, nil } func locateError(err error, path string, doc Document) *Error { d := validationError(err) d.File = path if d.Line == 0 { p := doc.Positions[d.Field] d.Line = p.Line d.Column = p.Column } for field := d.Field; field != ""; { if names := doc.environments[field]; len(names) > 0 { d.Env = strings.Join(names, ", ") break } i := strings.LastIndexAny(field, ".[") if i < 0 { break } field = field[:i] } return d } // Reject removed declarations rather than silently ignoring deployment rules. func rejectEnvironmentTags(t reflect.Type, seen map[reflect.Type]bool) error { if t == nil || seen[t] { return nil } seen[t] = true switch t.Kind() { case reflect.Pointer, reflect.Slice, reflect.Array, reflect.Map: return rejectEnvironmentTags(t.Elem(), seen) case reflect.Struct: for i := 0; i < t.NumField(); i++ { f := t.Field(i) if f.PkgPath != "" { continue } _, env := f.Tag.Lookup("env") _, def := f.Tag.Lookup("envDefault") if env || def { return &Error{Field: f.Name, Reason: "env/envDefault tags were removed; use Default() and explicit file references; see CONFIG.md"} } if f.Tag.Get("json") != "-" { if err := rejectEnvironmentTags(f.Type, seen); err != nil { return err } } } } return nil } var textType = reflect.TypeOf((*encoding.TextUnmarshaler)(nil)).Elem() var durationType = reflect.TypeOf(time.Duration(0)) func parseTextValue(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 parseTextValue(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 } return errors.New("expected scalar text") }