package configkit import ( "errors" "reflect" "testing" "time" ) type namedCount int32 type textValue struct { value string } func (v textValue) MarshalText() ([]byte, error) { return []byte(v.value), nil } func (v *textValue) UnmarshalText(data []byte) error { v.value = string(data) return nil } type marshalOnly string func (v marshalOnly) MarshalText() ([]byte, error) { return []byte(v), nil } type unmarshalOnly string func (v *unmarshalOnly) UnmarshalText(data []byte) error { *v = unmarshalOnly(data) return nil } type recursiveConfig struct { Name string `yaml:"name"` Next *recursiveConfig `yaml:"next"` Children map[string]recursiveConfig `yaml:"children"` } func TestBuildSchemaDescribesSupportedTypes(t *testing.T) { t.Parallel() type Embedded struct { InlineName string `yaml:"inline_name"` } type nested struct { Enabled bool `yaml:"enabled"` } type config struct { HTTPServer string `yaml:"server,omitempty" configkit:"secret"` Count namedCount `yaml:"count"` Delay time.Duration `yaml:"delay"` Custom textValue `yaml:"custom"` Optional *textValue `yaml:"optional"` Nested nested `yaml:"nested"` Objects map[string]nested `yaml:"objects"` Items []nested `yaml:"items"` Fixed [2]string `yaml:"fixed"` Dynamic any `yaml:"dynamic"` *Embedded `yaml:",inline"` Extras map[string]int `yaml:",inline"` UntaggedField bool Ignored chan int `yaml:"-"` private chan int } schema, issue := buildSchema(reflect.TypeFor[config]()) if issue != nil { t.Fatalf("buildSchema() issue = %#v", issue) } root := schema.root checks := map[string]descriptorKind{ "server": descriptorString, "count": descriptorSigned, "delay": descriptorDuration, "custom": descriptorText, "optional": descriptorPointer, "nested": descriptorStruct, "objects": descriptorMap, "items": descriptorSlice, "fixed": descriptorArray, "dynamic": descriptorDynamic, "inline_name": descriptorString, "untaggedfield": descriptorBoolean, } for name, want := range checks { position, ok := root.fieldByName[name] if !ok { t.Errorf("field %q missing", name) continue } got := root.fields[position].descriptor.kind if got != want { t.Errorf("field %q kind = %v, want %v", name, got, want) } } server := root.fields[root.fieldByName["server"]] if !server.secret { t.Fatal("secret tag was not retained") } if root.inlineMap == nil || root.inlineMap.descriptor.kind != descriptorMap { t.Fatalf("inline map = %#v", root.inlineMap) } if mapped, mapIssue := buildSchema(reflect.TypeFor[map[string]nested]()); mapIssue != nil || mapped.root.kind != descriptorMap { t.Fatalf("string-keyed root map = %#v, issue=%#v", mapped, mapIssue) } if _, exists := root.fieldByName["ignored"]; exists { t.Fatal("ignored field was bound") } } func TestBuildSchemaSupportsRecursiveDescriptorsWithoutSharingLoads(t *testing.T) { t.Parallel() first, issue := buildSchema(reflect.TypeFor[recursiveConfig]()) if issue != nil { t.Fatal(issue.message) } next := first.root.fields[first.root.fieldByName["next"]].descriptor if next.kind != descriptorPointer || next.element != first.root { t.Fatal("recursive pointer did not refer to the existing descriptor") } children := first.root.fields[first.root.fieldByName["children"]].descriptor if children.kind != descriptorMap || children.element != first.root { t.Fatal("recursive map did not refer to the existing descriptor") } second, issue := buildSchema(reflect.TypeFor[recursiveConfig]()) if issue != nil { t.Fatal(issue.message) } if first.root == second.root { t.Fatal("descriptor graph was cached across loads") } } func TestBuildSchemaRejectsInvalidRootsTypesTagsAndBindings(t *testing.T) { t.Parallel() type InlineA struct { Value string `yaml:"same"` } type InlineB struct { Value string `yaml:"same"` } type ambiguous struct { InlineA `yaml:",inline"` InlineB `yaml:",inline"` } type directConflict struct { Same string `yaml:"same"` InlineA `yaml:",inline"` } type twoMaps struct { First map[string]string `yaml:",inline"` Second map[string]string `yaml:",inline"` } type invalidInline struct { Value string `yaml:",inline"` } type invalidMap struct { Values map[int]string `yaml:"values"` } type unsupported struct { Value complex64 `yaml:"value"` } type badOption struct { Value string `yaml:"value,flow"` } type badConfigTag struct { Value string `configkit:"private"` } type missingMarshal struct { Value unmarshalOnly `yaml:"value"` } type missingUnmarshal struct { Value marshalOnly `yaml:"value"` } type RecursiveInline struct { *RecursiveInline `yaml:",inline"` } tests := []struct { name string typeOf reflect.Type code Code }{ {name: "nil root", typeOf: nil, code: CodeSchemaRoot}, {name: "pointer root", typeOf: reflect.TypeFor[*recursiveConfig](), code: CodeSchemaRoot}, {name: "slice root", typeOf: reflect.TypeFor[[]string](), code: CodeSchemaRoot}, {name: "non-string root map", typeOf: reflect.TypeFor[map[int]string](), code: CodeSchemaRoot}, {name: "text scalar root", typeOf: reflect.TypeFor[textValue](), code: CodeSchemaRoot}, {name: "ambiguous fields", typeOf: reflect.TypeFor[ambiguous](), code: CodeSchemaAmbiguousField}, {name: "direct inline conflict", typeOf: reflect.TypeFor[directConflict](), code: CodeSchemaAmbiguousField}, {name: "multiple inline maps", typeOf: reflect.TypeFor[twoMaps](), code: CodeSchemaInline}, {name: "invalid inline", typeOf: reflect.TypeFor[invalidInline](), code: CodeSchemaInline}, {name: "invalid nested map", typeOf: reflect.TypeFor[invalidMap](), code: CodeSchemaUnsupportedType}, {name: "unsupported kind", typeOf: reflect.TypeFor[unsupported](), code: CodeSchemaUnsupportedType}, {name: "unsupported YAML option", typeOf: reflect.TypeFor[badOption](), code: CodeSchemaTag}, {name: "unsupported ConfigKit tag", typeOf: reflect.TypeFor[badConfigTag](), code: CodeSchemaTag}, {name: "missing text marshal", typeOf: reflect.TypeFor[missingMarshal](), code: CodeSchemaTextCodec}, {name: "missing text unmarshal", typeOf: reflect.TypeFor[missingUnmarshal](), code: CodeSchemaTextCodec}, {name: "recursive inline", typeOf: reflect.TypeFor[RecursiveInline](), code: CodeSchemaInline}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { if _, issue := buildSchema(test.typeOf); issue == nil || issue.code != test.code { t.Fatalf("issue = %#v, want %s", issue, test.code) } }) } } func TestBuildSchemaDoesNotExecuteApplicationMethods(t *testing.T) { t.Parallel() type config struct { Value panicText `yaml:"value"` Structural yamlHookStruct `yaml:"structural"` } if _, issue := buildSchema(reflect.TypeFor[config]()); issue != nil { t.Fatal(issue.message) } } type panicText struct{} type yamlHookStruct struct { Value string `yaml:"value"` } func (*yamlHookStruct) UnmarshalYAML(any) error { panic("descriptor construction called parser hook") } func (panicText) MarshalText() ([]byte, error) { panic("descriptor construction called MarshalText") } func (*panicText) UnmarshalText([]byte) error { panic("descriptor construction called UnmarshalText") } func (panicText) UnmarshalYAML(any) error { panic("descriptor construction called parser hook") } func TestResolveSchemaPathUsesContainerSemantics(t *testing.T) { t.Parallel() type item struct { Name string `yaml:"name"` } type config struct { Items []item `yaml:"items"` Values map[string]item `yaml:"values"` Fixed [2]string `yaml:"fixed"` Dynamic any `yaml:"dynamic"` Extras map[string]string `yaml:",inline"` } schema, issue := buildSchema(reflect.TypeFor[config]()) if issue != nil { t.Fatal(issue.message) } valid := []Path{ Keys("items").Index(0).Key("name"), mustPath(t, "/items/0/name"), Keys("values", "0", "name"), mustPath(t, "/values/0/name"), Keys("fixed").Index(1), Keys("dynamic", "anything").Index(9), Keys("application_key"), } for _, path := range valid { if _, ok := resolveSchemaPath(schema, path); !ok { t.Errorf("valid path %q rejected", path.String()) } } invalid := []Path{ Keys("missing", "field"), Keys("items", "0", "name"), mustPath(t, "/items/00/name"), Keys("fixed").Index(2), Keys("values").Index(0), Path{}.Index(-1), } for _, path := range invalid { if _, ok := resolveSchemaPath(schema, path); ok { t.Errorf("invalid path %q accepted", path.String()) } } } func mustPath(t *testing.T, pointer string) Path { t.Helper() path, err := ParsePath(pointer) if err != nil { t.Fatal(err) } return path } func TestInlineMapPointersFailBeforeDefaults(t *testing.T) { type cfg struct { Extra *map[string]int `yaml:",inline"` } for _, nonNil := range []bool{false, true} { called := false _, err := Load(NoSource(), Options[cfg]{Definition: Definition[cfg]{Defaults: func() cfg { called = true if nonNil { m := map[string]int{"value": 1} return cfg{Extra: &m} } return cfg{} }}}) var loadErr *LoadError if !errors.As(err, &loadErr) || called { t.Fatalf("invalid inline schema reached defaults: called=%v error=%v", called, err) } if len(loadErr.Diagnostics()) != 1 || loadErr.Diagnostics()[0].Code != CodeSchemaInline { t.Fatal("missing inline schema diagnostic") } } type pointer *pointer type recursive struct { Extra pointer `yaml:",inline"` } if _, err := Load[recursive](NoSource()); err == nil { t.Fatal("recursive inline pointer was accepted") } type double struct { Extra **map[string]int `yaml:",inline"` } if _, err := Load[double](NoSource()); err == nil { t.Fatal("double inline map pointer was accepted") } }