From 2aad31529cd7970f0172d7526a9a400f610ad97b Mon Sep 17 00:00:00 2001 From: Richard Corfield Date: Wed, 24 Dec 2025 16:37:34 +0000 Subject: [PATCH] Separated the built-in types from the process pipeline --- custom_types.go | 13 +- internal/builtintypes/boolean_types.go | 24 ++ .../boolean_types_test.go | 6 +- .../duration.go | 10 +- .../duration_test.go | 6 +- .../json_types.go | 8 +- .../json_types_test.go | 6 +- .../number_types.go | 16 +- .../number_types_test.go | 12 +- .../ordered_validators.go | 12 +- .../pattern_validator.go | 8 +- .../pattern_validator_test.go | 2 +- internal/builtintypes/registry_test.go | 207 ++++++++++ internal/builtintypes/root_registry.go | 100 +++++ .../string_types.go | 10 +- .../string_types_test.go | 6 +- .../typed_handler.go | 14 +- .../url_type.go | 12 +- .../url_type_test.go | 2 +- internal/readpipeline/boolean_types.go | 22 - internal/readpipeline/invalid_types_test.go | 41 -- internal/readpipeline/process_test.go | 383 +++++++++--------- internal/readpipeline/typeregistry.go | 93 ----- internal/readpipeline/typeregistry_test.go | 200 --------- loadoptions.go | 3 +- 25 files changed, 615 insertions(+), 601 deletions(-) create mode 100644 internal/builtintypes/boolean_types.go rename internal/{readpipeline => builtintypes}/boolean_types_test.go (89%) rename internal/{readpipeline => builtintypes}/duration.go (69%) rename internal/{readpipeline => builtintypes}/duration_test.go (90%) rename internal/{readpipeline => builtintypes}/json_types.go (67%) rename internal/{readpipeline => builtintypes}/json_types_test.go (89%) rename internal/{readpipeline => builtintypes}/number_types.go (67%) rename internal/{readpipeline => builtintypes}/number_types_test.go (94%) rename internal/{readpipeline => builtintypes}/ordered_validators.go (75%) rename internal/{readpipeline => builtintypes}/pattern_validator.go (68%) rename internal/{readpipeline => builtintypes}/pattern_validator_test.go (98%) create mode 100644 internal/builtintypes/registry_test.go create mode 100644 internal/builtintypes/root_registry.go rename internal/{readpipeline => builtintypes}/string_types.go (58%) rename internal/{readpipeline => builtintypes}/string_types_test.go (94%) rename internal/{readpipeline => builtintypes}/typed_handler.go (76%) rename internal/{readpipeline => builtintypes}/url_type.go (67%) rename internal/{readpipeline => builtintypes}/url_type_test.go (99%) delete mode 100644 internal/readpipeline/boolean_types.go delete mode 100644 internal/readpipeline/invalid_types_test.go diff --git a/custom_types.go b/custom_types.go index 2ea7be2..70fa30a 100644 --- a/custom_types.go +++ b/custom_types.go @@ -4,6 +4,7 @@ import ( "reflect" "time" + "github.com/m0rjc/goconfig/internal/builtintypes" "github.com/m0rjc/goconfig/internal/customtypes" "github.com/m0rjc/goconfig/internal/readpipeline" ) @@ -19,7 +20,7 @@ type TypedHandler[T any] = readpipeline.TypedHandler[T] type Transform[T, U any] = customtypes.Transform[T, U] func RegisterCustomType[T any](handler TypedHandler[T]) { - readpipeline.RegisterType[T](handler) + builtintypes.RegisterType[T](handler) } func NewCustomType[T any](customParser FieldProcessor[T], customValidators ...Validator[T]) TypedHandler[T] { @@ -72,25 +73,25 @@ func TransformCustomType[T, U any](baseHandler TypedHandler[T], transform Transf } func DefaultStringType[T ~string]() TypedHandler[T] { - pipeline := readpipeline.NewTypedStringHandler() + pipeline := builtintypes.NewTypedStringHandler() return CastCustomType[string, T](pipeline) } func DefaultIntegerType[T ~int | ~int8 | ~int16 | ~int32 | ~int64]() TypedHandler[T] { t := reflect.TypeOf(T(0)) - return CastCustomType[int64, T](readpipeline.NewTypedIntHandler(t.Bits())) + return CastCustomType[int64, T](builtintypes.NewTypedIntHandler(t.Bits())) } func DefaultUnsignedIntegerType[T ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64]() TypedHandler[T] { t := reflect.TypeOf(T(0)) - return CastCustomType[uint64, T](readpipeline.NewTypedUintHandler(t.Bits())) + return CastCustomType[uint64, T](builtintypes.NewTypedUintHandler(t.Bits())) } func DefaultFloatIntegerType[T ~float32 | ~float64]() TypedHandler[T] { t := reflect.TypeOf(T(0)) - return CastCustomType[float64, T](readpipeline.NewTypedFloatHandler(t.Bits())) + return CastCustomType[float64, T](builtintypes.NewTypedFloatHandler(t.Bits())) } func DefaultDurationType() TypedHandler[time.Duration] { - return readpipeline.NewTypedDurationHandler() + return builtintypes.NewTypedDurationHandler() } diff --git a/internal/builtintypes/boolean_types.go b/internal/builtintypes/boolean_types.go new file mode 100644 index 0000000..d8aa85c --- /dev/null +++ b/internal/builtintypes/boolean_types.go @@ -0,0 +1,24 @@ +package builtintypes + +import ( + "reflect" + "strconv" + + "github.com/m0rjc/goconfig/internal/readpipeline" +) + +func NewBoolHandler(_ reflect.Type) readpipeline.TypedHandler[bool] { + return NewTypedBoolHandler() +} + +// NewTypedBoolHandler returns a TypedHandler[bool] that uses standard bool parsing and validation. +func NewTypedBoolHandler() readpipeline.TypedHandler[bool] { + return &typeHandlerImpl[bool]{ + Parser: func(rawValue string) (bool, error) { + return strconv.ParseBool(rawValue) + }, + ValidationWrapper: func(tags reflect.StructTag, inputProcess readpipeline.FieldProcessor[bool]) (readpipeline.FieldProcessor[bool], error) { + return inputProcess, nil + }, + } +} diff --git a/internal/readpipeline/boolean_types_test.go b/internal/builtintypes/boolean_types_test.go similarity index 89% rename from internal/readpipeline/boolean_types_test.go rename to internal/builtintypes/boolean_types_test.go index d712277..251c276 100644 --- a/internal/readpipeline/boolean_types_test.go +++ b/internal/builtintypes/boolean_types_test.go @@ -1,8 +1,10 @@ -package readpipeline +package builtintypes import ( "reflect" "testing" + + "github.com/m0rjc/goconfig/internal/readpipeline" ) func TestBoolTypes(t *testing.T) { @@ -49,7 +51,7 @@ func TestBoolTypes(t *testing.T) { registry := NewTypeRegistry() for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - proc, err := New(tt.fieldType, tt.tags, registry) + proc, err := readpipeline.New(tt.fieldType, tt.tags, registry) if err != nil { t.Fatalf("New() error = %v", err) } diff --git a/internal/readpipeline/duration.go b/internal/builtintypes/duration.go similarity index 69% rename from internal/readpipeline/duration.go rename to internal/builtintypes/duration.go index ed5c501..66ba592 100644 --- a/internal/readpipeline/duration.go +++ b/internal/builtintypes/duration.go @@ -1,11 +1,15 @@ -package readpipeline +package builtintypes -import "time" +import ( + "time" + + "github.com/m0rjc/goconfig/internal/readpipeline" +) var durationTypeHandler = NewTypedDurationHandler() // NewTypedDurationHandler returns a TypedHandler[time.Duration] that uses standard duration parsing and validation. -func NewTypedDurationHandler() TypedHandler[time.Duration] { +func NewTypedDurationHandler() readpipeline.TypedHandler[time.Duration] { return &typeHandlerImpl[time.Duration]{ Parser: func(rawValue string) (time.Duration, error) { return time.ParseDuration(rawValue) diff --git a/internal/readpipeline/duration_test.go b/internal/builtintypes/duration_test.go similarity index 90% rename from internal/readpipeline/duration_test.go rename to internal/builtintypes/duration_test.go index 0326281..40a6ce5 100644 --- a/internal/readpipeline/duration_test.go +++ b/internal/builtintypes/duration_test.go @@ -1,9 +1,11 @@ -package readpipeline +package builtintypes import ( "reflect" "testing" "time" + + "github.com/m0rjc/goconfig/internal/readpipeline" ) func TestDurationTypes(t *testing.T) { @@ -46,7 +48,7 @@ func TestDurationTypes(t *testing.T) { registry := NewTypeRegistry() for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - proc, err := New(tt.fieldType, tt.tags, registry) + proc, err := readpipeline.New(tt.fieldType, tt.tags, registry) if err != nil { t.Fatalf("New() error = %v", err) } diff --git a/internal/readpipeline/json_types.go b/internal/builtintypes/json_types.go similarity index 67% rename from internal/readpipeline/json_types.go rename to internal/builtintypes/json_types.go index 05db76d..a27bd76 100644 --- a/internal/readpipeline/json_types.go +++ b/internal/builtintypes/json_types.go @@ -1,12 +1,14 @@ -package readpipeline +package builtintypes import ( "encoding/json" "fmt" "reflect" + + "github.com/m0rjc/goconfig/internal/readpipeline" ) -func NewJsonPipelineBuilder(targetType reflect.Type) TypedHandler[any] { +func NewJsonPipelineBuilder(targetType reflect.Type) readpipeline.TypedHandler[any] { return &typeHandlerImpl[any]{ Parser: func(rawValue string) (any, error) { ptr := reflect.New(targetType).Interface() @@ -22,7 +24,7 @@ func NewJsonPipelineBuilder(targetType reflect.Type) TypedHandler[any] { return reflect.ValueOf(ptr).Elem().Interface(), nil }, - ValidationWrapper: func(tags reflect.StructTag, inputProcess FieldProcessor[any]) (FieldProcessor[any], error) { + ValidationWrapper: func(tags reflect.StructTag, inputProcess readpipeline.FieldProcessor[any]) (readpipeline.FieldProcessor[any], error) { return inputProcess, nil }, } diff --git a/internal/readpipeline/json_types_test.go b/internal/builtintypes/json_types_test.go similarity index 89% rename from internal/readpipeline/json_types_test.go rename to internal/builtintypes/json_types_test.go index f8609d4..4922264 100644 --- a/internal/readpipeline/json_types_test.go +++ b/internal/builtintypes/json_types_test.go @@ -1,8 +1,10 @@ -package readpipeline +package builtintypes import ( "reflect" "testing" + + "github.com/m0rjc/goconfig/internal/readpipeline" ) func TestJsonTypes(t *testing.T) { @@ -42,7 +44,7 @@ func TestJsonTypes(t *testing.T) { registry := NewTypeRegistry() for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - proc, err := New(tt.fieldType, tt.tags, registry) + proc, err := readpipeline.New(tt.fieldType, tt.tags, registry) if err != nil { t.Fatalf("New() error = %v", err) } diff --git a/internal/readpipeline/number_types.go b/internal/builtintypes/number_types.go similarity index 67% rename from internal/readpipeline/number_types.go rename to internal/builtintypes/number_types.go index 5fe0b03..98b9e74 100644 --- a/internal/readpipeline/number_types.go +++ b/internal/builtintypes/number_types.go @@ -1,24 +1,26 @@ -package readpipeline +package builtintypes import ( "reflect" "strconv" + + "github.com/m0rjc/goconfig/internal/readpipeline" ) -func NewIntHandler(fieldType reflect.Type) TypedHandler[int64] { +func NewIntHandler(fieldType reflect.Type) readpipeline.TypedHandler[int64] { return NewTypedIntHandler(fieldType.Bits()) } -func NewUintHandler(fieldType reflect.Type) TypedHandler[uint64] { +func NewUintHandler(fieldType reflect.Type) readpipeline.TypedHandler[uint64] { return NewTypedUintHandler(fieldType.Bits()) } -func NewFloatHandler(fieldType reflect.Type) TypedHandler[float64] { +func NewFloatHandler(fieldType reflect.Type) readpipeline.TypedHandler[float64] { return NewTypedFloatHandler(fieldType.Bits()) } // NewTypedIntHandler returns a TypedHandler[int64] that uses standard int parsing and validation. -func NewTypedIntHandler(bits int) TypedHandler[int64] { +func NewTypedIntHandler(bits int) readpipeline.TypedHandler[int64] { return &typeHandlerImpl[int64]{ Parser: func(rawValue string) (int64, error) { return strconv.ParseInt(rawValue, 0, bits) @@ -28,7 +30,7 @@ func NewTypedIntHandler(bits int) TypedHandler[int64] { } // NewTypedUintHandler returns a TypedHandler[uint64] that uses standard uint parsing and validation. -func NewTypedUintHandler(bits int) TypedHandler[uint64] { +func NewTypedUintHandler(bits int) readpipeline.TypedHandler[uint64] { return &typeHandlerImpl[uint64]{ Parser: func(rawValue string) (uint64, error) { return strconv.ParseUint(rawValue, 0, bits) @@ -38,7 +40,7 @@ func NewTypedUintHandler(bits int) TypedHandler[uint64] { } // NewTypedFloatHandler returns a TypedHandler[float64] that uses standard float parsing and validation. -func NewTypedFloatHandler(bits int) TypedHandler[float64] { +func NewTypedFloatHandler(bits int) readpipeline.TypedHandler[float64] { return &typeHandlerImpl[float64]{ Parser: func(rawValue string) (float64, error) { return strconv.ParseFloat(rawValue, bits) diff --git a/internal/readpipeline/number_types_test.go b/internal/builtintypes/number_types_test.go similarity index 94% rename from internal/readpipeline/number_types_test.go rename to internal/builtintypes/number_types_test.go index cd4c8cd..2d3686a 100644 --- a/internal/readpipeline/number_types_test.go +++ b/internal/builtintypes/number_types_test.go @@ -1,8 +1,10 @@ -package readpipeline +package builtintypes import ( "reflect" "testing" + + "github.com/m0rjc/goconfig/internal/readpipeline" ) func TestIntTypes(t *testing.T) { @@ -111,7 +113,7 @@ func TestIntTypes(t *testing.T) { registry := NewTypeRegistry() t.Run("invalid min tag", func(t *testing.T) { - _, err := New(reflect.TypeOf(int(0)), `min:"foo"`, registry) + _, err := readpipeline.New(reflect.TypeOf(int(0)), `min:"foo"`, registry) if err == nil { t.Error("expected error for invalid min tag, got nil") } @@ -119,7 +121,7 @@ func TestIntTypes(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - proc, err := New(tt.fieldType, tt.tags, registry) + proc, err := readpipeline.New(tt.fieldType, tt.tags, registry) if err != nil { t.Fatalf("New() error = %v", err) } @@ -217,7 +219,7 @@ func TestUintTypes(t *testing.T) { registry := NewTypeRegistry() for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - proc, err := New(tt.fieldType, tt.tags, registry) + proc, err := readpipeline.New(tt.fieldType, tt.tags, registry) if err != nil { t.Fatalf("New() error = %v", err) } @@ -267,7 +269,7 @@ func TestFloatTypes(t *testing.T) { registry := NewTypeRegistry() for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - proc, err := New(tt.fieldType, tt.tags, registry) + proc, err := readpipeline.New(tt.fieldType, tt.tags, registry) if err != nil { t.Fatalf("New() error = %v", err) } diff --git a/internal/readpipeline/ordered_validators.go b/internal/builtintypes/ordered_validators.go similarity index 75% rename from internal/readpipeline/ordered_validators.go rename to internal/builtintypes/ordered_validators.go index 4616767..039ba2c 100644 --- a/internal/readpipeline/ordered_validators.go +++ b/internal/builtintypes/ordered_validators.go @@ -1,9 +1,11 @@ -package readpipeline +package builtintypes import ( "cmp" "fmt" "reflect" + + "github.com/m0rjc/goconfig/internal/readpipeline" ) // orderedValidator is a validator that checks a value is within a range. The value must be comparable. @@ -37,7 +39,7 @@ func newRangeValidator[T cmp.Ordered](minimum, maximum T) orderedValidator[T] { } // WrapProcessUsingRangeTags applies the min and max tags to an ordered readpipeline. -func WrapProcessUsingRangeTags[T cmp.Ordered](tags reflect.StructTag, processor FieldProcessor[T]) (FieldProcessor[T], error) { +func WrapProcessUsingRangeTags[T cmp.Ordered](tags reflect.StructTag, processor readpipeline.FieldProcessor[T]) (readpipeline.FieldProcessor[T], error) { minTag, hasMin := tags.Lookup("min") maxTag, hasMax := tags.Lookup("max") @@ -57,13 +59,13 @@ func WrapProcessUsingRangeTags[T cmp.Ordered](tags reflect.StructTag, processor } if hasMin && hasMax { - return Pipe(processor, Validator[T](newRangeValidator(minimum, maximum))), nil + return readpipeline.Pipe(processor, readpipeline.Validator[T](newRangeValidator(minimum, maximum))), nil } if hasMin { - return Pipe(processor, Validator[T](newMinValidator(minimum))), nil + return readpipeline.Pipe(processor, readpipeline.Validator[T](newMinValidator(minimum))), nil } if hasMax { - return Pipe(processor, Validator[T](newMaxValidator(maximum))), nil + return readpipeline.Pipe(processor, readpipeline.Validator[T](newMaxValidator(maximum))), nil } return processor, nil } diff --git a/internal/readpipeline/pattern_validator.go b/internal/builtintypes/pattern_validator.go similarity index 68% rename from internal/readpipeline/pattern_validator.go rename to internal/builtintypes/pattern_validator.go index 74c7330..aed3707 100644 --- a/internal/readpipeline/pattern_validator.go +++ b/internal/builtintypes/pattern_validator.go @@ -1,13 +1,15 @@ -package readpipeline +package builtintypes import ( "fmt" "reflect" "regexp" + + "github.com/m0rjc/goconfig/internal/readpipeline" ) // WrapProcessUsingPatternTag applies the pattern tag validation if present. -func WrapProcessUsingPatternTag(tags reflect.StructTag, processor FieldProcessor[string]) (FieldProcessor[string], error) { +func WrapProcessUsingPatternTag(tags reflect.StructTag, processor readpipeline.FieldProcessor[string]) (readpipeline.FieldProcessor[string], error) { patternTag, hasPattern := tags.Lookup("pattern") if hasPattern { @@ -15,7 +17,7 @@ func WrapProcessUsingPatternTag(tags reflect.StructTag, processor FieldProcessor if err != nil { return nil, err } - return Pipe(processor, func(value string) error { + return readpipeline.Pipe(processor, func(value string) error { if !pattern.MatchString(value) { return fmt.Errorf("does not match pattern %s", patternTag) } diff --git a/internal/readpipeline/pattern_validator_test.go b/internal/builtintypes/pattern_validator_test.go similarity index 98% rename from internal/readpipeline/pattern_validator_test.go rename to internal/builtintypes/pattern_validator_test.go index 9f375c1..f88ec12 100644 --- a/internal/readpipeline/pattern_validator_test.go +++ b/internal/builtintypes/pattern_validator_test.go @@ -1,4 +1,4 @@ -package readpipeline +package builtintypes import ( "reflect" diff --git a/internal/builtintypes/registry_test.go b/internal/builtintypes/registry_test.go new file mode 100644 index 0000000..e364d08 --- /dev/null +++ b/internal/builtintypes/registry_test.go @@ -0,0 +1,207 @@ +package builtintypes + +import ( + "net/url" + "reflect" + "testing" + "time" + + "github.com/m0rjc/goconfig/internal/readpipeline" +) + +// registryMockPipelineBuilder is a simple implementation of PipelineBuilder for testing +type registryMockPipelineBuilder struct { + buildFunc func(tags reflect.StructTag) (readpipeline.FieldProcessor[any], error) +} + +func (m *registryMockPipelineBuilder) Build(tags reflect.StructTag) (readpipeline.FieldProcessor[any], error) { + if m.buildFunc != nil { + return m.buildFunc(tags) + } + return nil, nil +} + +func TestLocalTypeRegistry(t *testing.T) { + parent := &LocalTypeRegistry{ + SpecialTypeHandlers: map[reflect.Type]readpipeline.PipelineBuilder{}, + } + registry := &LocalTypeRegistry{ + Parent: parent, + SpecialTypeHandlers: map[reflect.Type]readpipeline.PipelineBuilder{}, + } + + intType := reflect.TypeOf(0) + stringType := reflect.TypeOf("") + + handler1 := ®istryMockPipelineBuilder{} + handler2 := ®istryMockPipelineBuilder{} + + t.Run("RegisterAndRetrieve", func(t *testing.T) { + registry.RegisterType(intType, handler1) + got := registry.HandlerFor(intType) + if got != handler1 { + t.Errorf("expected handler1, got %v", got) + } + }) + + t.Run("FallbackToParent", func(t *testing.T) { + parent.RegisterType(stringType, handler2) + got := registry.HandlerFor(stringType) + if got != handler2 { + t.Errorf("expected handler2 from parent, got %v", got) + } + }) + + t.Run("OverrideParent", func(t *testing.T) { + registry.RegisterType(stringType, handler1) + got := registry.HandlerFor(stringType) + if got != handler1 { + t.Errorf("expected handler1 (override), got %v", got) + } + }) + + t.Run("NotFound", func(t *testing.T) { + boolType := reflect.TypeOf(true) + // We need a parent that returns nil if not found + root := &RootTypeRegistry{ + specialTypeHandlers: map[reflect.Type]readpipeline.PipelineBuilder{}, + kindHandlers: map[reflect.Kind]readpipeline.HandlerFactory{}, + } + regWithRoot := &LocalTypeRegistry{ + Parent: root, + SpecialTypeHandlers: map[reflect.Type]readpipeline.PipelineBuilder{}, + } + got := regWithRoot.HandlerFor(boolType) + if got != nil { + t.Errorf("expected nil, got %v", got) + } + }) +} + +func TestRootTypeRegistry(t *testing.T) { + registry := &RootTypeRegistry{ + specialTypeHandlers: map[reflect.Type]readpipeline.PipelineBuilder{}, + kindHandlers: map[reflect.Kind]readpipeline.HandlerFactory{}, + } + + intType := reflect.TypeOf(0) + handler1 := ®istryMockPipelineBuilder{} + + t.Run("SpecialTypeHandler", func(t *testing.T) { + registry.RegisterType(intType, handler1) + got := registry.HandlerFor(intType) + if got != handler1 { + t.Errorf("expected handler1, got %v", got) + } + }) + + t.Run("KindHandler", func(t *testing.T) { + stringType := reflect.TypeOf("") + handler2 := ®istryMockPipelineBuilder{} + registry.kindHandlers[reflect.String] = func(t reflect.Type) readpipeline.PipelineBuilder { + return handler2 + } + + got := registry.HandlerFor(stringType) + if got != handler2 { + t.Errorf("expected handler2 from kind factory, got %v", got) + } + }) + + t.Run("NotFound", func(t *testing.T) { + boolType := reflect.TypeOf(true) + got := registry.HandlerFor(boolType) + if got != nil { + t.Errorf("expected nil, got %v", got) + } + }) +} + +func TestNewTypeRegistry(t *testing.T) { + registry := NewTypeRegistry() + if registry == nil { + t.Fatal("NewTypeRegistry returned nil") + } + + local, ok := registry.(*LocalTypeRegistry) + if !ok { + t.Fatalf("expected *LocalTypeRegistry, got %T", registry) + } + + if local.Parent != rootRegistry { + t.Error("expected parent to be rootRegistry") + } +} + +func TestRegisterTypeGlobal(t *testing.T) { + type CustomType struct { + Value string + } + + // mockTypedHandler is a simple implementation of TypedHandler for testing + handler := &typeHandlerImpl[CustomType]{ + Parser: func(rawValue string) (CustomType, error) { + return CustomType{Value: rawValue}, nil + }, + } + + RegisterType[CustomType](handler) + + // Verify it's in rootRegistry + customType := reflect.TypeOf(CustomType{}) + pb := rootRegistry.HandlerFor(customType) + if pb == nil { + t.Fatal("expected handler to be registered in rootRegistry") + } + + pipeline, err := pb.Build("") + if err != nil { + t.Fatalf("Build failed: %v", err) + } + + val, err := pipeline("hello") + if err != nil { + t.Fatalf("pipeline failed: %v", err) + } + + expected := CustomType{Value: "hello"} + if val != expected { + t.Errorf("expected %v, got %v", expected, val) + } +} + +func TestDefaultHandlers(t *testing.T) { + tests := []struct { + name string + val any + }{ + {"Int", 0}, + {"Int8", int8(0)}, + {"Int16", int16(0)}, + {"Int32", int32(0)}, + {"Int64", int64(0)}, + {"Uint", uint(0)}, + {"Uint8", uint8(0)}, + {"Uint16", uint16(0)}, + {"Uint32", uint32(0)}, + {"Uint64", uint64(0)}, + {"String", ""}, + {"Bool", true}, + {"Float32", float32(0)}, + {"Float64", float64(0)}, + {"Duration", time.Duration(0)}, + {"URL", (*url.URL)(nil)}, + {"Struct", struct{ X int }{}}, + {"Map", map[string]int{}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + typ := reflect.TypeOf(tt.val) + handler := rootRegistry.HandlerFor(typ) + if handler == nil { + t.Errorf("no default handler for %v", typ) + } + }) + } +} diff --git a/internal/builtintypes/root_registry.go b/internal/builtintypes/root_registry.go new file mode 100644 index 0000000..b8d0c38 --- /dev/null +++ b/internal/builtintypes/root_registry.go @@ -0,0 +1,100 @@ +package builtintypes + +import ( + "net/url" + "reflect" + "time" + + "github.com/m0rjc/goconfig/internal/readpipeline" +) + +// RootTypeRegistry is a registry of Handlers factories for specific types. +// Handlers can be registered for specific types or for a category of types keyed on Kind. +// If a handler is registered for a specific type, it will be used instead of the category handler. +// If a handler is registered for a category, a factory method is called to instantiate the handler given the type. +type RootTypeRegistry struct { + specialTypeHandlers map[reflect.Type]readpipeline.PipelineBuilder + kindHandlers map[reflect.Kind]readpipeline.HandlerFactory +} + +// RegisterType registers a custom readpipeline.PipelineBuilder for a given type. +func (r *RootTypeRegistry) RegisterType(t reflect.Type, handler readpipeline.PipelineBuilder) { + r.specialTypeHandlers[t] = handler +} + +// RegisterKind registers a factory method for a given Kind. +func (r *RootTypeRegistry) RegisterKind(kind reflect.Kind, factory readpipeline.HandlerFactory) { + r.kindHandlers[kind] = factory +} + +// HandlerFor returns the readpipeline.PipelineBuilder for the given type, or nil if none is registered. +func (r *RootTypeRegistry) HandlerFor(t reflect.Type) readpipeline.PipelineBuilder { + // 1. Check for specific type overrides (The "Duration" check) + if p, ok := r.specialTypeHandlers[t]; ok { + return p + } + + // 2. Fall back to category-based logic + if factory, ok := r.kindHandlers[t.Kind()]; ok { + return factory(t) + } + + return nil +} + +// NewTypeRegistry creates a new TypeRegistry with the default handlers. +// Types registered here will override the default handlers for this registry instance only +func NewTypeRegistry() readpipeline.TypeRegistry { + return &LocalTypeRegistry{ + Parent: rootRegistry, + SpecialTypeHandlers: map[reflect.Type]readpipeline.PipelineBuilder{}, + } +} + +// RegisterType registers a custom PipelineBuilder for a given type in the root registry. +func RegisterType[T any](handler readpipeline.TypedHandler[T]) { + handlerType := reflect.TypeOf((*T)(nil)).Elem() + wrapper := readpipeline.WrapTypedHandler(handler) + rootRegistry.RegisterType(handlerType, wrapper) +} + +type LocalTypeRegistry struct { + Parent readpipeline.TypeRegistry + SpecialTypeHandlers map[reflect.Type]readpipeline.PipelineBuilder +} + +func (r *LocalTypeRegistry) RegisterType(t reflect.Type, handler readpipeline.PipelineBuilder) { + r.SpecialTypeHandlers[t] = handler +} + +func (r *LocalTypeRegistry) HandlerFor(t reflect.Type) readpipeline.PipelineBuilder { + if p, ok := r.SpecialTypeHandlers[t]; ok { + return p + } + return r.Parent.HandlerFor(t) +} + +var rootRegistry = &RootTypeRegistry{ + specialTypeHandlers: map[reflect.Type]readpipeline.PipelineBuilder{ + reflect.TypeOf(time.Duration(0)): readpipeline.WrapTypedHandler(durationTypeHandler), + reflect.TypeOf((*url.URL)(nil)): readpipeline.WrapTypedHandler(NewUrlTypedHandler()), + }, + kindHandlers: map[reflect.Kind]readpipeline.HandlerFactory{ + reflect.Int: readpipeline.WrapKindHandler(NewIntHandler), + reflect.Int8: readpipeline.WrapKindHandler(NewIntHandler), + reflect.Int16: readpipeline.WrapKindHandler(NewIntHandler), + reflect.Int32: readpipeline.WrapKindHandler(NewIntHandler), + reflect.Int64: readpipeline.WrapKindHandler(NewIntHandler), + reflect.Uint: readpipeline.WrapKindHandler(NewUintHandler), + reflect.Uint8: readpipeline.WrapKindHandler(NewUintHandler), + reflect.Uint16: readpipeline.WrapKindHandler(NewUintHandler), + reflect.Uint32: readpipeline.WrapKindHandler(NewUintHandler), + reflect.Uint64: readpipeline.WrapKindHandler(NewUintHandler), + reflect.Struct: readpipeline.WrapKindHandler(NewJsonPipelineBuilder), + reflect.Map: readpipeline.WrapKindHandler(NewJsonPipelineBuilder), + reflect.String: readpipeline.WrapKindHandler(NewStringHandler), + reflect.Bool: readpipeline.WrapKindHandler(NewBoolHandler), + reflect.Float32: readpipeline.WrapKindHandler(NewFloatHandler), + reflect.Float64: readpipeline.WrapKindHandler(NewFloatHandler), + }, +} diff --git a/internal/readpipeline/string_types.go b/internal/builtintypes/string_types.go similarity index 58% rename from internal/readpipeline/string_types.go rename to internal/builtintypes/string_types.go index cf2e6e2..26e813b 100644 --- a/internal/readpipeline/string_types.go +++ b/internal/builtintypes/string_types.go @@ -1,21 +1,23 @@ -package readpipeline +package builtintypes import ( "reflect" + + "github.com/m0rjc/goconfig/internal/readpipeline" ) // NewStringHandler returns a TypedHandler[string] that simply returns the raw value. // Strings support the min and max tags for lexical ordering and the pattern tag for regex -func NewStringHandler(_ reflect.Type) TypedHandler[string] { +func NewStringHandler(_ reflect.Type) readpipeline.TypedHandler[string] { return NewTypedStringHandler() } // NewTypedStringHandler returns a TypedHandler[string] that uses standard string parsing and validation. -func NewTypedStringHandler() TypedHandler[string] { +func NewTypedStringHandler() readpipeline.TypedHandler[string] { return &typeHandlerImpl[string]{ Parser: func(rawValue string) (string, error) { return rawValue, nil }, - ValidationWrapper: NewCompositeWrapper(WrapProcessUsingPatternTag, WrapProcessUsingRangeTags[string]), + ValidationWrapper: readpipeline.NewCompositeWrapper(WrapProcessUsingPatternTag, WrapProcessUsingRangeTags[string]), } } diff --git a/internal/readpipeline/string_types_test.go b/internal/builtintypes/string_types_test.go similarity index 94% rename from internal/readpipeline/string_types_test.go rename to internal/builtintypes/string_types_test.go index af676f0..0fd18f9 100644 --- a/internal/readpipeline/string_types_test.go +++ b/internal/builtintypes/string_types_test.go @@ -1,8 +1,10 @@ -package readpipeline +package builtintypes import ( "reflect" "testing" + + "github.com/m0rjc/goconfig/internal/readpipeline" ) func TestStringTypes(t *testing.T) { @@ -88,7 +90,7 @@ func TestStringTypes(t *testing.T) { registry := NewTypeRegistry() for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - proc, err := New(tt.fieldType, tt.tags, registry) + proc, err := readpipeline.New(tt.fieldType, tt.tags, registry) if err != nil { t.Fatalf("New() error = %v", err) } diff --git a/internal/readpipeline/typed_handler.go b/internal/builtintypes/typed_handler.go similarity index 76% rename from internal/readpipeline/typed_handler.go rename to internal/builtintypes/typed_handler.go index 20a4af0..be5150b 100644 --- a/internal/readpipeline/typed_handler.go +++ b/internal/builtintypes/typed_handler.go @@ -1,17 +1,21 @@ -package readpipeline +package builtintypes -import "reflect" +import ( + "reflect" + + "github.com/m0rjc/goconfig/internal/readpipeline" +) // typeHandlerImpl is the strongly typed handler for the given pipeline. // It implements the typeless PipelineBuilder interface for the pipeline by boxing and unboxing the value as required. type typeHandlerImpl[T any] struct { // Parser is the strongly typed version of the FieldProcessor that acts as input for this readpipeline - Parser FieldProcessor[T] + Parser readpipeline.FieldProcessor[T] // ValidationWrapper is a factory that wraps the FieldProcessor with validation stages - ValidationWrapper Wrapper[T] + ValidationWrapper readpipeline.Wrapper[T] } -func (h *typeHandlerImpl[T]) BuildPipeline(tags reflect.StructTag) (FieldProcessor[T], error) { +func (h *typeHandlerImpl[T]) BuildPipeline(tags reflect.StructTag) (readpipeline.FieldProcessor[T], error) { pipeline := h.Parser if pipeline == nil { return nil, nil diff --git a/internal/readpipeline/url_type.go b/internal/builtintypes/url_type.go similarity index 67% rename from internal/readpipeline/url_type.go rename to internal/builtintypes/url_type.go index 66371de..4743ccc 100644 --- a/internal/readpipeline/url_type.go +++ b/internal/builtintypes/url_type.go @@ -1,4 +1,4 @@ -package readpipeline +package builtintypes import ( "fmt" @@ -6,23 +6,25 @@ import ( "reflect" "regexp" "strings" + + "github.com/m0rjc/goconfig/internal/readpipeline" ) -func NewUrlTypedHandler() TypedHandler[*url.URL] { +func NewUrlTypedHandler() readpipeline.TypedHandler[*url.URL] { return &typeHandlerImpl[*url.URL]{ Parser: url.ParseRequestURI, ValidationWrapper: wrapUrlPipeline, } } -func wrapUrlPipeline(tags reflect.StructTag, pipeline FieldProcessor[*url.URL]) (FieldProcessor[*url.URL], error) { +func wrapUrlPipeline(tags reflect.StructTag, pipeline readpipeline.FieldProcessor[*url.URL]) (readpipeline.FieldProcessor[*url.URL], error) { patternTag := tags.Get("pattern") if patternTag != "" { pattern, err := regexp.Compile(patternTag) if err != nil { return nil, err } - pipeline = Pipe(pipeline, func(value *url.URL) error { + pipeline = readpipeline.Pipe(pipeline, func(value *url.URL) error { if !pattern.MatchString(value.String()) { return fmt.Errorf("does not match pattern %s", patternTag) } @@ -34,7 +36,7 @@ func wrapUrlPipeline(tags reflect.StructTag, pipeline FieldProcessor[*url.URL]) schemeTag := tags.Get("scheme") if schemeTag != "" { schemes := strings.Split(schemeTag, ",") - pipeline = Pipe(pipeline, func(value *url.URL) error { + pipeline = readpipeline.Pipe(pipeline, func(value *url.URL) error { for _, scheme := range schemes { if scheme == value.Scheme { return nil diff --git a/internal/readpipeline/url_type_test.go b/internal/builtintypes/url_type_test.go similarity index 99% rename from internal/readpipeline/url_type_test.go rename to internal/builtintypes/url_type_test.go index 5f58e33..056e8de 100644 --- a/internal/readpipeline/url_type_test.go +++ b/internal/builtintypes/url_type_test.go @@ -1,4 +1,4 @@ -package readpipeline +package builtintypes import ( "net/url" diff --git a/internal/readpipeline/boolean_types.go b/internal/readpipeline/boolean_types.go deleted file mode 100644 index b4adf89..0000000 --- a/internal/readpipeline/boolean_types.go +++ /dev/null @@ -1,22 +0,0 @@ -package readpipeline - -import ( - "reflect" - "strconv" -) - -func NewBoolHandler(_ reflect.Type) TypedHandler[bool] { - return NewTypedBoolHandler() -} - -// NewTypedBoolHandler returns a TypedHandler[bool] that uses standard bool parsing and validation. -func NewTypedBoolHandler() TypedHandler[bool] { - return &typeHandlerImpl[bool]{ - Parser: func(rawValue string) (bool, error) { - return strconv.ParseBool(rawValue) - }, - ValidationWrapper: func(tags reflect.StructTag, inputProcess FieldProcessor[bool]) (FieldProcessor[bool], error) { - return inputProcess, nil - }, - } -} diff --git a/internal/readpipeline/invalid_types_test.go b/internal/readpipeline/invalid_types_test.go deleted file mode 100644 index 3125ddc..0000000 --- a/internal/readpipeline/invalid_types_test.go +++ /dev/null @@ -1,41 +0,0 @@ -package readpipeline - -import ( - "reflect" - "testing" -) - -func TestInvalidTypes(t *testing.T) { - // This test will need to be removed or replaced if we ever support complex numbers - registry := NewTypeRegistry() - t.Run("Complex128", func(t *testing.T) { - fieldType := reflect.TypeOf(complex128(0)) - tags := reflect.StructTag("") - - _, err := New(fieldType, tags, registry) - if err == nil { - t.Fatal("Expected error for complex128 type, but got nil") - } - - expectedErr := "no handler for type complex128" - if err.Error() != expectedErr { - t.Errorf("Expected error %q, got %q", expectedErr, err.Error()) - } - }) - - t.Run("Interface", func(t *testing.T) { - var i any - fieldType := reflect.TypeOf(&i).Elem() - tags := reflect.StructTag("") - - _, err := New(fieldType, tags, registry) - if err == nil { - t.Fatal("Expected error for interface type, but got nil") - } - - expectedErr := "no handler for type interface {}" - if err.Error() != expectedErr { - t.Errorf("Expected error %q, got %q", expectedErr, err.Error()) - } - }) -} diff --git a/internal/readpipeline/process_test.go b/internal/readpipeline/process_test.go index dcc413b..b782c15 100644 --- a/internal/readpipeline/process_test.go +++ b/internal/readpipeline/process_test.go @@ -1,188 +1,195 @@ -package readpipeline - -import ( - "errors" - "reflect" - "testing" -) - -// mockPipelineBuilder is a mock implementation of PipelineBuilder for testing. -type mockPipelineBuilder struct { - buildFunc func(tags reflect.StructTag) (FieldProcessor[any], error) -} - -func (m *mockPipelineBuilder) Build(tags reflect.StructTag) (FieldProcessor[any], error) { - return m.buildFunc(tags) -} - -func TestNew(t *testing.T) { - t.Run("BareType", func(t *testing.T) { - registry := &rootTypeRegistry{ - specialTypeHandlers: make(map[reflect.Type]PipelineBuilder), - kindHandlers: make(map[reflect.Kind]HandlerFactory), - } - - expectedValue := "success" - mockBuilder := &mockPipelineBuilder{ - buildFunc: func(tags reflect.StructTag) (FieldProcessor[any], error) { - return func(rawValue string) (any, error) { - return expectedValue, nil - }, nil - }, - } - - stringType := reflect.TypeOf("") - registry.RegisterType(stringType, mockBuilder) - - processor, err := New(stringType, "", registry) - if err != nil { - t.Fatalf("New failed: %v", err) - } - - val, err := processor("input") - if err != nil { - t.Fatalf("Processor failed: %v", err) - } - - if val != expectedValue { - t.Errorf("Expected %v, got %v", expectedValue, val) - } - }) - - t.Run("PointerType", func(t *testing.T) { - registry := &rootTypeRegistry{ - specialTypeHandlers: make(map[reflect.Type]PipelineBuilder), - kindHandlers: make(map[reflect.Kind]HandlerFactory), - } - - expectedValue := 42 - mockBuilder := &mockPipelineBuilder{ - buildFunc: func(tags reflect.StructTag) (FieldProcessor[any], error) { - return func(rawValue string) (any, error) { - return expectedValue, nil - }, nil - }, - } - - intType := reflect.TypeOf(0) - registry.RegisterType(intType, mockBuilder) - - // Pass *int to New - ptrIntType := reflect.TypeOf((*int)(nil)) - processor, err := New(ptrIntType, "", registry) - if err != nil { - t.Fatalf("New failed: %v", err) - } - - val, err := processor("input") - if err != nil { - t.Fatalf("Processor failed: %v", err) - } - - if val != expectedValue { - t.Errorf("Expected %v, got %v", expectedValue, val) - } - }) - - t.Run("NoHandlerError", func(t *testing.T) { - registry := &rootTypeRegistry{ - specialTypeHandlers: make(map[reflect.Type]PipelineBuilder), - kindHandlers: make(map[reflect.Kind]HandlerFactory), - } - - _, err := New(reflect.TypeOf(0), "", registry) - if err == nil { - t.Fatal("Expected error for missing handler, got nil") - } - - expectedErr := "no handler for type int" - if err.Error() != expectedErr { - t.Errorf("Expected error %q, got %q", expectedErr, err.Error()) - } - }) - - t.Run("BuildError", func(t *testing.T) { - registry := &rootTypeRegistry{ - specialTypeHandlers: make(map[reflect.Type]PipelineBuilder), - kindHandlers: make(map[reflect.Kind]HandlerFactory), - } - - expectedBuildErr := errors.New("build failed") - mockBuilder := &mockPipelineBuilder{ - buildFunc: func(tags reflect.StructTag) (FieldProcessor[any], error) { - return nil, expectedBuildErr - }, - } - - intType := reflect.TypeOf(0) - registry.RegisterType(intType, mockBuilder) - - _, err := New(intType, "", registry) - if err == nil { - t.Fatal("Expected error from Build, got nil") - } - - if !errors.Is(err, expectedBuildErr) { - t.Errorf("Expected error %v, got %v", expectedBuildErr, err) - } - }) - - t.Run("NilPipelineError", func(t *testing.T) { - registry := &rootTypeRegistry{ - specialTypeHandlers: make(map[reflect.Type]PipelineBuilder), - kindHandlers: make(map[reflect.Kind]HandlerFactory), - } - - mockBuilder := &mockPipelineBuilder{ - buildFunc: func(tags reflect.StructTag) (FieldProcessor[any], error) { - return nil, nil - }, - } - - intType := reflect.TypeOf(0) - registry.RegisterType(intType, mockBuilder) - - _, err := New(intType, "", registry) - if err == nil { - t.Fatal("Expected error for nil pipeline, got nil") - } - - expectedErr := "no parser for type int" - if err.Error() != expectedErr { - t.Errorf("Expected error %q, got %q", expectedErr, err.Error()) - } - }) - - t.Run("ProcessorError", func(t *testing.T) { - registry := &rootTypeRegistry{ - specialTypeHandlers: make(map[reflect.Type]PipelineBuilder), - kindHandlers: make(map[reflect.Kind]HandlerFactory), - } - - expectedProcErr := errors.New("processing failed") - mockBuilder := &mockPipelineBuilder{ - buildFunc: func(tags reflect.StructTag) (FieldProcessor[any], error) { - return func(rawValue string) (any, error) { - return nil, expectedProcErr - }, nil - }, - } - - intType := reflect.TypeOf(0) - registry.RegisterType(intType, mockBuilder) - - processor, err := New(intType, "", registry) - if err != nil { - t.Fatalf("New failed: %v", err) - } - - _, err = processor("input") - if err == nil { - t.Fatal("Expected error from processor, got nil") - } - - if !errors.Is(err, expectedProcErr) { - t.Errorf("Expected error %v, got %v", expectedProcErr, err) - } - }) -} +package readpipeline + +import ( + "errors" + "reflect" + "testing" +) + +// mockPipelineBuilder is a mock implementation of PipelineBuilder for testing. +type mockPipelineBuilder struct { + buildFunc func(tags reflect.StructTag) (FieldProcessor[any], error) +} + +func (m *mockPipelineBuilder) Build(tags reflect.StructTag) (FieldProcessor[any], error) { + return m.buildFunc(tags) +} + +// mockRegistry is a mock implementation of TypeRegistry for testing. +type mockRegistry struct { + handlers map[reflect.Type]PipelineBuilder +} + +func (m *mockRegistry) RegisterType(t reflect.Type, handler PipelineBuilder) { + m.handlers[t] = handler +} + +func (m *mockRegistry) HandlerFor(t reflect.Type) PipelineBuilder { + return m.handlers[t] +} + +func TestNew(t *testing.T) { + t.Run("BareType", func(t *testing.T) { + registry := &mockRegistry{ + handlers: make(map[reflect.Type]PipelineBuilder), + } + + expectedValue := "success" + mockBuilder := &mockPipelineBuilder{ + buildFunc: func(tags reflect.StructTag) (FieldProcessor[any], error) { + return func(rawValue string) (any, error) { + return expectedValue, nil + }, nil + }, + } + + stringType := reflect.TypeOf("") + registry.RegisterType(stringType, mockBuilder) + + processor, err := New(stringType, "", registry) + if err != nil { + t.Fatalf("New failed: %v", err) + } + + val, err := processor("input") + if err != nil { + t.Fatalf("Processor failed: %v", err) + } + + if val != expectedValue { + t.Errorf("Expected %v, got %v", expectedValue, val) + } + }) + + t.Run("PointerType", func(t *testing.T) { + registry := &mockRegistry{ + handlers: make(map[reflect.Type]PipelineBuilder), + } + + expectedValue := 42 + mockBuilder := &mockPipelineBuilder{ + buildFunc: func(tags reflect.StructTag) (FieldProcessor[any], error) { + return func(rawValue string) (any, error) { + return expectedValue, nil + }, nil + }, + } + + intType := reflect.TypeOf(0) + registry.RegisterType(intType, mockBuilder) + + // Pass *int to New + ptrIntType := reflect.TypeOf((*int)(nil)) + processor, err := New(ptrIntType, "", registry) + if err != nil { + t.Fatalf("New failed: %v", err) + } + + val, err := processor("input") + if err != nil { + t.Fatalf("Processor failed: %v", err) + } + + if val != expectedValue { + t.Errorf("Expected %v, got %v", expectedValue, val) + } + }) + + t.Run("NoHandlerError", func(t *testing.T) { + registry := &mockRegistry{ + handlers: make(map[reflect.Type]PipelineBuilder), + } + + _, err := New(reflect.TypeOf(0), "", registry) + if err == nil { + t.Fatal("Expected error for missing handler, got nil") + } + + expectedErr := "no handler for type int" + if err.Error() != expectedErr { + t.Errorf("Expected error %q, got %q", expectedErr, err.Error()) + } + }) + + t.Run("BuildError", func(t *testing.T) { + registry := &mockRegistry{ + handlers: make(map[reflect.Type]PipelineBuilder), + } + + expectedBuildErr := errors.New("build failed") + mockBuilder := &mockPipelineBuilder{ + buildFunc: func(tags reflect.StructTag) (FieldProcessor[any], error) { + return nil, expectedBuildErr + }, + } + + intType := reflect.TypeOf(0) + registry.RegisterType(intType, mockBuilder) + + _, err := New(intType, "", registry) + if err == nil { + t.Fatal("Expected error from Build, got nil") + } + + if !errors.Is(err, expectedBuildErr) { + t.Errorf("Expected error %v, got %v", expectedBuildErr, err) + } + }) + + t.Run("NilPipelineError", func(t *testing.T) { + registry := &mockRegistry{ + handlers: make(map[reflect.Type]PipelineBuilder), + } + + mockBuilder := &mockPipelineBuilder{ + buildFunc: func(tags reflect.StructTag) (FieldProcessor[any], error) { + return nil, nil + }, + } + + intType := reflect.TypeOf(0) + registry.RegisterType(intType, mockBuilder) + + _, err := New(intType, "", registry) + if err == nil { + t.Fatal("Expected error for nil pipeline, got nil") + } + + expectedErr := "no parser for type int" + if err.Error() != expectedErr { + t.Errorf("Expected error %q, got %q", expectedErr, err.Error()) + } + }) + + t.Run("ProcessorError", func(t *testing.T) { + registry := &mockRegistry{ + handlers: make(map[reflect.Type]PipelineBuilder), + } + + expectedProcErr := errors.New("processing failed") + mockBuilder := &mockPipelineBuilder{ + buildFunc: func(tags reflect.StructTag) (FieldProcessor[any], error) { + return func(rawValue string) (any, error) { + return nil, expectedProcErr + }, nil + }, + } + + intType := reflect.TypeOf(0) + registry.RegisterType(intType, mockBuilder) + + processor, err := New(intType, "", registry) + if err != nil { + t.Fatalf("New failed: %v", err) + } + + _, err = processor("input") + if err == nil { + t.Fatal("Expected error from processor, got nil") + } + + if !errors.Is(err, expectedProcErr) { + t.Errorf("Expected error %v, got %v", expectedProcErr, err) + } + }) +} diff --git a/internal/readpipeline/typeregistry.go b/internal/readpipeline/typeregistry.go index a9032f6..bfb30bc 100644 --- a/internal/readpipeline/typeregistry.go +++ b/internal/readpipeline/typeregistry.go @@ -1,9 +1,7 @@ package readpipeline import ( - "net/url" "reflect" - "time" ) // HandlerFactory is a function that returns a PipelineBuilder for a given type. @@ -17,72 +15,6 @@ type TypeRegistry interface { HandlerFor(t reflect.Type) PipelineBuilder } -// NewTypeRegistry creates a new TypeRegistry with the default handlers. -// Types registered here will override the default handlers for this registry instance only -func NewTypeRegistry() TypeRegistry { - return &localTypeRegistry{ - parent: rootRegistry, - specialTypeHandlers: map[reflect.Type]PipelineBuilder{}, - } -} - -// RegisterType registers a custom PipelineBuilder for a given type in the root registry. -func RegisterType[T any](handler TypedHandler[T]) { - handlerType := reflect.TypeOf((*T)(nil)).Elem() - wrapper := WrapTypedHandler(handler) - rootRegistry.RegisterType(handlerType, wrapper) -} - -type localTypeRegistry struct { - parent TypeRegistry - specialTypeHandlers map[reflect.Type]PipelineBuilder -} - -func (r *localTypeRegistry) RegisterType(t reflect.Type, handler PipelineBuilder) { - r.specialTypeHandlers[t] = handler -} - -func (r *localTypeRegistry) HandlerFor(t reflect.Type) PipelineBuilder { - if p, ok := r.specialTypeHandlers[t]; ok { - return p - } - return r.parent.HandlerFor(t) -} - -// BaseTypeRegistry is a registry of Handlers factories for specific types. -// Handlers can be registered for specific types or for a category of types keyed on Kind. -// If a handler is registered for a specific type, it will be used instead of the category handler. -// If a handler is registered for a category, a factory method is called to instantiate the handler given the type. -type rootTypeRegistry struct { - specialTypeHandlers map[reflect.Type]PipelineBuilder - kindHandlers map[reflect.Kind]HandlerFactory -} - -// RegisterType registers a custom PipelineBuilder for a given type. -func (r *rootTypeRegistry) RegisterType(t reflect.Type, handler PipelineBuilder) { - r.specialTypeHandlers[t] = handler -} - -// RegisterKind registers a factory method for a given Kind. -func (r *rootTypeRegistry) RegisterKind(kind reflect.Kind, factory HandlerFactory) { - r.kindHandlers[kind] = factory -} - -// HandlerFor returns the PipelineBuilder for the given type, or nil if none is registered. -func (r *rootTypeRegistry) HandlerFor(t reflect.Type) PipelineBuilder { - // 1. Check for specific type overrides (The "Duration" check) - if p, ok := r.specialTypeHandlers[t]; ok { - return p - } - - // 2. Fall back to category-based logic - if factory, ok := r.kindHandlers[t.Kind()]; ok { - return factory(t) - } - - return nil -} - // typedHandlerAdapter adapts a TypedHandler[T] to a PipelineBuilder. type typedHandlerAdapter[T any] struct { Handler TypedHandler[T] @@ -112,28 +44,3 @@ func WrapKindHandler[T any](handler TypedHandlerFactory[T]) HandlerFactory { return WrapTypedHandler(handler(t)) } } - -var rootRegistry = &rootTypeRegistry{ - specialTypeHandlers: map[reflect.Type]PipelineBuilder{ - reflect.TypeOf(time.Duration(0)): WrapTypedHandler(durationTypeHandler), - reflect.TypeOf((*url.URL)(nil)): WrapTypedHandler(NewUrlTypedHandler()), - }, - kindHandlers: map[reflect.Kind]HandlerFactory{ - reflect.Int: WrapKindHandler(NewIntHandler), - reflect.Int8: WrapKindHandler(NewIntHandler), - reflect.Int16: WrapKindHandler(NewIntHandler), - reflect.Int32: WrapKindHandler(NewIntHandler), - reflect.Int64: WrapKindHandler(NewIntHandler), - reflect.Uint: WrapKindHandler(NewUintHandler), - reflect.Uint8: WrapKindHandler(NewUintHandler), - reflect.Uint16: WrapKindHandler(NewUintHandler), - reflect.Uint32: WrapKindHandler(NewUintHandler), - reflect.Uint64: WrapKindHandler(NewUintHandler), - reflect.Struct: WrapKindHandler(NewJsonPipelineBuilder), - reflect.Map: WrapKindHandler(NewJsonPipelineBuilder), - reflect.String: WrapKindHandler(NewStringHandler), - reflect.Bool: WrapKindHandler(NewBoolHandler), - reflect.Float32: WrapKindHandler(NewFloatHandler), - reflect.Float64: WrapKindHandler(NewFloatHandler), - }, -} diff --git a/internal/readpipeline/typeregistry_test.go b/internal/readpipeline/typeregistry_test.go index 02c5b33..ff335bd 100644 --- a/internal/readpipeline/typeregistry_test.go +++ b/internal/readpipeline/typeregistry_test.go @@ -2,10 +2,8 @@ package readpipeline import ( "errors" - "net/url" "reflect" "testing" - "time" ) // mockTypedHandler is a simple implementation of TypedHandler for testing @@ -20,114 +18,6 @@ func (m *mockTypedHandler[T]) BuildPipeline(tags reflect.StructTag) (FieldProces return nil, nil } -// registryMockPipelineBuilder is a simple implementation of PipelineBuilder for testing -type registryMockPipelineBuilder struct { - buildFunc func(tags reflect.StructTag) (FieldProcessor[any], error) -} - -func (m *registryMockPipelineBuilder) Build(tags reflect.StructTag) (FieldProcessor[any], error) { - if m.buildFunc != nil { - return m.buildFunc(tags) - } - return nil, nil -} - -func TestLocalTypeRegistry(t *testing.T) { - parent := &localTypeRegistry{ - specialTypeHandlers: map[reflect.Type]PipelineBuilder{}, - } - registry := &localTypeRegistry{ - parent: parent, - specialTypeHandlers: map[reflect.Type]PipelineBuilder{}, - } - - intType := reflect.TypeOf(0) - stringType := reflect.TypeOf("") - - handler1 := ®istryMockPipelineBuilder{} - handler2 := ®istryMockPipelineBuilder{} - - t.Run("RegisterAndRetrieve", func(t *testing.T) { - registry.RegisterType(intType, handler1) - got := registry.HandlerFor(intType) - if got != handler1 { - t.Errorf("expected handler1, got %v", got) - } - }) - - t.Run("FallbackToParent", func(t *testing.T) { - parent.RegisterType(stringType, handler2) - got := registry.HandlerFor(stringType) - if got != handler2 { - t.Errorf("expected handler2 from parent, got %v", got) - } - }) - - t.Run("OverrideParent", func(t *testing.T) { - registry.RegisterType(stringType, handler1) - got := registry.HandlerFor(stringType) - if got != handler1 { - t.Errorf("expected handler1 (override), got %v", got) - } - }) - - t.Run("NotFound", func(t *testing.T) { - boolType := reflect.TypeOf(true) - // We need a parent that returns nil if not found - root := &rootTypeRegistry{ - specialTypeHandlers: map[reflect.Type]PipelineBuilder{}, - kindHandlers: map[reflect.Kind]HandlerFactory{}, - } - regWithRoot := &localTypeRegistry{ - parent: root, - specialTypeHandlers: map[reflect.Type]PipelineBuilder{}, - } - got := regWithRoot.HandlerFor(boolType) - if got != nil { - t.Errorf("expected nil, got %v", got) - } - }) -} - -func TestRootTypeRegistry(t *testing.T) { - registry := &rootTypeRegistry{ - specialTypeHandlers: map[reflect.Type]PipelineBuilder{}, - kindHandlers: map[reflect.Kind]HandlerFactory{}, - } - - intType := reflect.TypeOf(0) - handler1 := ®istryMockPipelineBuilder{} - - t.Run("SpecialTypeHandler", func(t *testing.T) { - registry.RegisterType(intType, handler1) - got := registry.HandlerFor(intType) - if got != handler1 { - t.Errorf("expected handler1, got %v", got) - } - }) - - t.Run("KindHandler", func(t *testing.T) { - stringType := reflect.TypeOf("") - handler2 := ®istryMockPipelineBuilder{} - registry.kindHandlers[reflect.String] = func(t reflect.Type) PipelineBuilder { - return handler2 - } - - got := registry.HandlerFor(stringType) - if got != handler2 { - t.Errorf("expected handler2 from kind factory, got %v", got) - } - }) - - t.Run("NotFound", func(t *testing.T) { - boolType := reflect.TypeOf(true) - got := registry.HandlerFor(boolType) - if got != nil { - t.Errorf("expected nil, got %v", got) - } - }) -} - func TestTypedHandlerAdapter(t *testing.T) { t.Run("Success", func(t *testing.T) { inner := &mockTypedHandler[int]{ @@ -184,93 +74,3 @@ func TestTypedHandlerAdapter(t *testing.T) { } }) } - -func TestNewTypeRegistry(t *testing.T) { - registry := NewTypeRegistry() - if registry == nil { - t.Fatal("NewTypeRegistry returned nil") - } - - local, ok := registry.(*localTypeRegistry) - if !ok { - t.Fatalf("expected *localTypeRegistry, got %T", registry) - } - - if local.parent != rootRegistry { - t.Error("expected parent to be rootRegistry") - } -} - -func TestRegisterTypeGlobal(t *testing.T) { - type CustomType struct { - Value string - } - - handler := &mockTypedHandler[CustomType]{ - buildPipelineFunc: func(tags reflect.StructTag) (FieldProcessor[CustomType], error) { - return func(rawValue string) (CustomType, error) { - return CustomType{Value: rawValue}, nil - }, nil - }, - } - - RegisterType[CustomType](handler) - - // Verify it's in rootRegistry - customType := reflect.TypeOf(CustomType{}) - pb := rootRegistry.HandlerFor(customType) - if pb == nil { - t.Fatal("expected handler to be registered in rootRegistry") - } - - pipeline, err := pb.Build("") - if err != nil { - t.Fatalf("Build failed: %v", err) - } - - val, err := pipeline("hello") - if err != nil { - t.Fatalf("pipeline failed: %v", err) - } - - expected := CustomType{Value: "hello"} - if val != expected { - t.Errorf("expected %v, got %v", expected, val) - } -} - -func TestDefaultHandlers(t *testing.T) { - tests := []struct { - name string - val any - }{ - {"Int", 0}, - {"Int8", int8(0)}, - {"Int16", int16(0)}, - {"Int32", int32(0)}, - {"Int64", int64(0)}, - {"Uint", uint(0)}, - {"Uint8", uint8(0)}, - {"Uint16", uint16(0)}, - {"Uint32", uint32(0)}, - {"Uint64", uint64(0)}, - {"String", ""}, - {"Bool", true}, - {"Float32", float32(0)}, - {"Float64", float64(0)}, - {"Duration", time.Duration(0)}, - {"URL", (*url.URL)(nil)}, - {"Struct", struct{ X int }{}}, - {"Map", map[string]int{}}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - typ := reflect.TypeOf(tt.val) - handler := rootRegistry.HandlerFor(typ) - if handler == nil { - t.Errorf("no default handler for %v", typ) - } - }) - } -} diff --git a/loadoptions.go b/loadoptions.go index eed1d1d..b5f561d 100644 --- a/loadoptions.go +++ b/loadoptions.go @@ -3,6 +3,7 @@ package goconfig import ( "reflect" + "github.com/m0rjc/goconfig/internal/builtintypes" "github.com/m0rjc/goconfig/internal/readpipeline" ) @@ -39,7 +40,7 @@ type loadOptions struct { func newLoadOptions() *loadOptions { return &loadOptions{ keyStore: EnvironmentKeyStore, - typeRegistry: readpipeline.NewTypeRegistry(), + typeRegistry: builtintypes.NewTypeRegistry(), } }