diff --git a/binding.go b/binding.go index e8f6513..fa0477f 100644 --- a/binding.go +++ b/binding.go @@ -387,17 +387,91 @@ func convertValue(rawValue any, targetType reflect.Type) (any, error) { return val, nil case reflect.Slice: - // Handle []string + // Preserve the existing []string convenience behavior (comma-separated strings). if targetType.Elem().Kind() == reflect.String { return parseStringSlice(rawValue) } - return nil, fmt.Errorf("unsupported slice type: %s", targetType) + + rawValueRef := reflect.ValueOf(rawValue) + if rawValueRef.Kind() != reflect.Slice && rawValueRef.Kind() != reflect.Array { + return nil, fmt.Errorf("cannot convert %T to %s", rawValue, targetType) + } + + result := reflect.MakeSlice(targetType, rawValueRef.Len(), rawValueRef.Len()) + for i := 0; i < rawValueRef.Len(); i++ { + elem, err := convertCollectionElement(rawValueRef.Index(i).Interface(), targetType.Elem()) + if err != nil { + return nil, fmt.Errorf("cannot convert slice element %d: %w", i, err) + } + result.Index(i).Set(reflect.ValueOf(elem)) + } + return result.Interface(), nil + + case reflect.Map: + if targetType.Key().Kind() != reflect.String { + return nil, fmt.Errorf("unsupported map key type: %s", targetType.Key()) + } + + rawValueRef := reflect.ValueOf(rawValue) + if rawValueRef.Kind() != reflect.Map { + return nil, fmt.Errorf("cannot convert %T to %s", rawValue, targetType) + } + + result := reflect.MakeMapWithSize(targetType, rawValueRef.Len()) + for _, rawKey := range rawValueRef.MapKeys() { + if rawKey.Kind() != reflect.String { + return nil, fmt.Errorf("cannot convert map key type %s to string", rawKey.Type()) + } + + elem, err := convertCollectionElement(rawValueRef.MapIndex(rawKey).Interface(), targetType.Elem()) + if err != nil { + return nil, fmt.Errorf("cannot convert map value for key %q: %w", rawKey.String(), err) + } + result.SetMapIndex(reflect.ValueOf(rawKey.String()).Convert(targetType.Key()), reflect.ValueOf(elem)) + } + return result.Interface(), nil default: return nil, fmt.Errorf("unsupported target type: %s", targetType) } } +func convertCollectionElement(rawValue any, targetType reflect.Type) (any, error) { + // Collection elements/values that are structs need real binding, not "return raw map as-is". + if rawValue != nil && + targetType.Kind() == reflect.Struct && + targetType != timeType && + targetType != durationType && + !isOptionalType(targetType) { + return convertStructValue(rawValue, targetType) + } + + return convertValue(rawValue, targetType) +} + +func convertStructValue(rawValue any, targetType reflect.Type) (any, error) { + rawValueRef := reflect.ValueOf(rawValue) + if !rawValueRef.IsValid() || rawValueRef.Kind() != reflect.Map { + return convertValue(rawValue, targetType) + } + + nestedData := make(map[string]mergedEntry, rawValueRef.Len()) + for _, rawKey := range rawValueRef.MapKeys() { + if rawKey.Kind() != reflect.String { + return nil, fmt.Errorf("cannot bind nested struct %s with non-string key type %s", targetType, rawKey.Type()) + } + nestedData[strings.ToLower(rawKey.String())] = mergedEntry{value: rawValueRef.MapIndex(rawKey).Interface()} + } + + nestedTarget := reflect.New(targetType).Elem() + fieldErrors := bindStruct(nestedTarget, nestedData, nil, "", "") + if len(fieldErrors) > 0 { + return nil, fmt.Errorf("cannot bind nested struct %s at %s: %s", targetType, fieldErrors[0].FieldPath, fieldErrors[0].Message) + } + + return nestedTarget.Interface(), nil +} + // parseBool parses a boolean value from a string. // Accepts: "true", "false", "1", "0", "yes", "no" (case-insensitive) func parseBool(s string) (bool, error) { diff --git a/loader_test.go b/loader_test.go index 8e73a0b..542401f 100644 --- a/loader_test.go +++ b/loader_test.go @@ -734,6 +734,93 @@ func TestLoad_NestedStruct(t *testing.T) { } } +func TestLoad_NestedCollections(t *testing.T) { + type ClickHouseConfig struct { + Host string + Port int + } + + type Config struct { + ClickhouseList []ClickHouseConfig + ClickhouseMap map[string]ClickHouseConfig + } + + t.Run("binds slice and direct map values", func(t *testing.T) { + source := &mockSource{ + data: map[string]any{ + "clickhouse_list": []any{ + map[string]any{"host": "ch1", "port": 9000}, + map[string]any{"host": "ch2", "port": 9001}, + }, + "clickhouse_map": map[string]any{ + "primary": map[string]any{"host": "ch1", "port": 9000}, + "replica": map[string]any{"host": "ch2", "port": 9001}, + "analytics": map[string]any{"host": "ch3", "port": 9002}, + }, + }, + } + + cfg, err := NewLoader[Config]().WithSource(source).Load(context.Background()) + if err != nil { + t.Fatalf("Load failed: %v", err) + } + + wantList := []ClickHouseConfig{ + {Host: "ch1", Port: 9000}, + {Host: "ch2", Port: 9001}, + } + if !reflect.DeepEqual(cfg.ClickhouseList, wantList) { + t.Errorf("ClickhouseList = %#v, want %#v", cfg.ClickhouseList, wantList) + } + + wantMap := map[string]ClickHouseConfig{ + "primary": {Host: "ch1", Port: 9000}, + "replica": {Host: "ch2", Port: 9001}, + "analytics": {Host: "ch3", Port: 9002}, + } + if !reflect.DeepEqual(cfg.ClickhouseMap, wantMap) { + t.Errorf("ClickhouseMap = %#v, want %#v", cfg.ClickhouseMap, wantMap) + } + }) + + t.Run("invalid nested collection type reports invalid_type", func(t *testing.T) { + source := &mockSource{ + data: map[string]any{ + "clickhouse_list": []any{ + map[string]any{"host": "ch1", "port": "not-an-int"}, + }, + }, + } + + cfg, err := NewLoader[Config]().WithSource(source).Load(context.Background()) + if err == nil { + t.Fatal("expected invalid_type error") + } + if cfg != nil { + t.Fatal("expected nil cfg on invalid_type error") + } + + valErr, ok := err.(*ValidationError) + if !ok { + t.Fatalf("expected ValidationError, got %T", err) + } + if len(valErr.FieldErrors) == 0 { + t.Fatal("expected at least one field error") + } + + fieldErr := valErr.FieldErrors[0] + if fieldErr.Code != ErrCodeInvalidType { + t.Fatalf("expected code %q, got %q", ErrCodeInvalidType, fieldErr.Code) + } + if fieldErr.FieldPath != "ClickhouseList" { + t.Fatalf("expected FieldPath %q, got %q", "ClickhouseList", fieldErr.FieldPath) + } + if !strings.Contains(fieldErr.Message, "slice element 0") { + t.Fatalf("expected index context in error message, got %q", fieldErr.Message) + } + }) +} + // TestLoad_SourceError verifies that source load errors are propagated. func TestLoad_SourceError(t *testing.T) { type Config struct {