package config import ( "fmt" "reflect" "strings" ) // validateStruct returns a list of human-readable validation errors found while // walking the struct tree. Validation is driven by the `validate` struct tag, // which holds comma-separated rules. Supported rules: required, min=N. func validateStruct(sv reflect.Value) []error { var errs []error st := sv.Type() for i := 0; i < st.NumField(); i++ { field := st.Field(i) if !field.IsExported() { continue } fv := sv.Field(i) if rules := field.Tag.Get("validate"); rules != "" { name := envTagName(field.Tag.Get("env")) if name == "" { name = field.Name } errs = append(errs, applyRules(name, rules, fv)...) } if fv.Kind() == reflect.Struct { errs = append(errs, validateStruct(fv)...) } } return errs } func applyRules(name, rules string, fv reflect.Value) []error { var errs []error for _, rule := range strings.Split(rules, ",") { rule = strings.TrimSpace(rule) if rule == "" { continue } ruleName, arg, _ := strings.Cut(rule, "=") switch ruleName { case "required": if isZero(fv) { errs = append(errs, fmt.Errorf("%s is required", name)) } case "min": if err := applyMin(name, arg, fv); err != nil { errs = append(errs, err) } } } return errs } func isZero(fv reflect.Value) bool { switch fv.Kind() { case reflect.String: return fv.String() == "" case reflect.Bool: return !fv.Bool() case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: return fv.Int() == 0 default: return fv.IsZero() } } func applyMin(name, arg string, fv reflect.Value) error { if fv.Kind() != reflect.String { return nil } min, err := parseIntArg(arg) if err != nil { return nil } if len(fv.String()) < min { return fmt.Errorf("%s must be at least %d characters", name, min) } return nil } func parseIntArg(arg string) (int, error) { if arg == "" { return 0, fmt.Errorf("missing min argument") } n := 0 for _, c := range arg { if c < '0' || c > '9' { return 0, fmt.Errorf("invalid min argument %q", arg) } n = n*10 + int(c-'0') } return n, nil }