// FILE: ./internal/normalizers/common/round.go package common import ( "math" "reflect" ) // DefaultFloatPrecision is the project-wide wire-format policy for floating-point // values in canonical payloads (weather.* schemas). // // Note: encoding/json will not necessarily print trailing zeros (e.g. 1.50 -> 1.5), // but values will be *rounded* to this number of digits after the decimal point. const DefaultFloatPrecision = 2 // RoundFloats returns a copy of v with all float32/float64 values (including pointers, // slices, arrays, maps, and nested exported-struct fields) rounded to `decimals` digits // after the decimal point. // // This is a best-effort helper meant for presentation stability. If reflection hits an // unsupported/opaque type (e.g. structs with unexported fields like time.Time), that // subtree is left unchanged. func RoundFloats(v any, decimals int) any { if v == nil || decimals < 0 { return v } defer func() { // Never let presentation formatting crash the pipeline. _ = recover() }() rv := reflect.ValueOf(v) out := roundValue(rv, decimals) if !out.IsValid() { return v } return out.Interface() } func roundValue(v reflect.Value, decimals int) reflect.Value { if !v.IsValid() { return v } // Unwrap interfaces. if v.Kind() == reflect.Interface { if v.IsNil() { return v } elem := roundValue(v.Elem(), decimals) // Re-wrap in the same interface type. out := reflect.New(v.Type()).Elem() if elem.IsValid() && elem.Type().AssignableTo(v.Type()) { out.Set(elem) return out } if elem.IsValid() && elem.Type().AssignableTo(v.Type()) { out.Set(elem) return out } if elem.IsValid() && elem.Type().ConvertibleTo(v.Type()) { out.Set(elem.Convert(v.Type())) return out } // If we can't sensibly re-wrap, just keep the original. return v } // Copy pointers (and round their targets). if v.Kind() == reflect.Pointer { if v.IsNil() { return v } // If the pointed-to type is an opaque struct (e.g. time.Time), keep as-is. if v.Elem().Kind() == reflect.Struct && isOpaqueStruct(v.Elem().Type()) { return v } elem := roundValue(v.Elem(), decimals) p := reflect.New(v.Type().Elem()) if elem.IsValid() && elem.Type().AssignableTo(v.Type().Elem()) { p.Elem().Set(elem) } else if elem.IsValid() && elem.Type().ConvertibleTo(v.Type().Elem()) { p.Elem().Set(elem.Convert(v.Type().Elem())) } else { p.Elem().Set(v.Elem()) } return p } switch v.Kind() { case reflect.Float32, reflect.Float64: f := v.Convert(reflect.TypeOf(float64(0))).Float() r := roundFloat64(f, decimals) return reflect.ValueOf(r).Convert(v.Type()) case reflect.Struct: // Avoid reconstructing opaque structs (time.Time has unexported fields). if isOpaqueStruct(v.Type()) { return v } out := reflect.New(v.Type()).Elem() out.Set(v) // start from a copy, then replace rounded fields t := v.Type() for i := 0; i < v.NumField(); i++ { sf := t.Field(i) // Only exported fields are safely settable across packages. if sf.PkgPath != "" { continue } fv := v.Field(i) rf := roundValue(fv, decimals) of := out.Field(i) if !of.CanSet() { continue } if rf.IsValid() && rf.Type().AssignableTo(of.Type()) { of.Set(rf) } else if rf.IsValid() && rf.Type().ConvertibleTo(of.Type()) { of.Set(rf.Convert(of.Type())) } } return out case reflect.Slice: if v.IsNil() { return v } out := reflect.MakeSlice(v.Type(), v.Len(), v.Len()) for i := 0; i < v.Len(); i++ { ev := v.Index(i) re := roundValue(ev, decimals) if re.IsValid() && re.Type().AssignableTo(out.Index(i).Type()) { out.Index(i).Set(re) } else if re.IsValid() && re.Type().ConvertibleTo(out.Index(i).Type()) { out.Index(i).Set(re.Convert(out.Index(i).Type())) } else { out.Index(i).Set(ev) } } return out case reflect.Array: out := reflect.New(v.Type()).Elem() out.Set(v) for i := 0; i < v.Len(); i++ { ev := v.Index(i) re := roundValue(ev, decimals) if re.IsValid() && re.Type().AssignableTo(out.Index(i).Type()) { out.Index(i).Set(re) } else if re.IsValid() && re.Type().ConvertibleTo(out.Index(i).Type()) { out.Index(i).Set(re.Convert(out.Index(i).Type())) } else { out.Index(i).Set(ev) } } return out case reflect.Map: if v.IsNil() { return v } out := reflect.MakeMapWithSize(v.Type(), v.Len()) iter := v.MapRange() for iter.Next() { k := iter.Key() mv := iter.Value() rv := roundValue(mv, decimals) if rv.IsValid() && rv.Type().AssignableTo(v.Type().Elem()) { out.SetMapIndex(k, rv) } else if rv.IsValid() && rv.Type().ConvertibleTo(v.Type().Elem()) { out.SetMapIndex(k, rv.Convert(v.Type().Elem())) } else { out.SetMapIndex(k, mv) } } return out default: // ints, strings, bools, time.Time (handled as opaque), etc. return v } } func roundFloat64(f float64, decimals int) float64 { if decimals <= 0 { return math.Round(f) } pow := math.Pow10(decimals) return math.Round(f*pow) / pow } // isOpaqueStruct returns true for structs that are unsafe/unhelpful to reconstruct via reflection. // Any struct containing unexported fields (e.g. time.Time) is treated as opaque. func isOpaqueStruct(t reflect.Type) bool { if t.Kind() != reflect.Struct { return false } for i := 0; i < t.NumField(); i++ { if t.Field(i).PkgPath != "" { return true } } return false }