diff --git a/staging/src/k8s.io/apimachinery/pkg/api/validate/union.go b/staging/src/k8s.io/apimachinery/pkg/api/validate/union.go new file mode 100644 index 00000000000..06f7ef6f01c --- /dev/null +++ b/staging/src/k8s.io/apimachinery/pkg/api/validate/union.go @@ -0,0 +1,212 @@ +/* +Copyright 2024 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package validate + +import ( + "context" + "fmt" + "strings" + + "k8s.io/apimachinery/pkg/api/operation" + "k8s.io/apimachinery/pkg/util/validation/field" +) + +// ExtractorFn extracts a value from a parent object. Depending on the context, +// that could be the value of a field or just whether that field was set or +// not. +// Note: obj is not guaranteed to be non-nil, need to handle nil obj in the +// extractor. +type ExtractorFn[T, V any] func(obj T) V + +// UnionValidationOptions configures how union validation behaves +type UnionValidationOptions struct { + // ErrorForEmpty returns error when no fields are set (nil means no error) + ErrorForEmpty func(fldPath *field.Path, allFields []string) *field.Error + + // ErrorForMultiple returns error when multiple fields are set (nil means no error) + ErrorForMultiple func(fldPath *field.Path, specifiedFields []string, allFields []string) *field.Error +} + +// Union verifies that exactly one member of a union is specified. +// +// UnionMembership must define all the members of the union. +// +// For example: +// +// var UnionMembershipForABC := validate.NewUnionMembership([2]string{"a", "A"}, [2]string{"b", "B"}, [2]string{"c", "C"}) +// func ValidateABC(ctx context.Context, op operation.Operation, fldPath *field.Path, in *ABC) (errs fields.ErrorList) { +// errs = append(errs, Union(ctx, op, fldPath, in, oldIn, UnionMembershipForABC, +// func(in *ABC) bool { return in.A != nil }, +// func(in *ABC) bool { return in.B != ""}, +// func(in *ABC) bool { return in.C != 0 }, +// )...) +// return errs +// } +func Union[T any](_ context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj T, union *UnionMembership, isSetFns ...ExtractorFn[T, bool]) field.ErrorList { + options := UnionValidationOptions{ + ErrorForEmpty: func(fldPath *field.Path, allFields []string) *field.Error { + return field.Invalid(fldPath, "", + fmt.Sprintf("must specify one of: %s", strings.Join(allFields, ", "))) + }, + ErrorForMultiple: func(fldPath *field.Path, specifiedFields []string, allFields []string) *field.Error { + return field.Invalid(fldPath, fmt.Sprintf("{%s}", strings.Join(specifiedFields, ", ")), + fmt.Sprintf("must specify exactly one of: %s", strings.Join(allFields, ", "))) + }, + } + + return unionValidate(op, fldPath, obj, oldObj, union, options, isSetFns...) +} + +// DiscriminatedUnion verifies specified union member matches the discriminator. +// +// UnionMembership must define all the members of the union and the discriminator. +// +// For example: +// +// var UnionMembershipForABC := validate.NewDiscriminatedUnionMembership("type", [2]string{"a", "A"}, [2]string{"b" "B"}, [2]string{"c", "C"}) +// func ValidateABC(ctx context.Context, op operation.Operation, fldPath, *field.Path, in *ABC) (errs fields.ErrorList) { +// errs = append(errs, DiscriminatedUnion(ctx, op, fldPath, in, oldIn, UnionMembershipForABC, +// func(in *ABC) string { return string(in.Type) }, +// func(in *ABC) bool { return in.A != nil }, +// func(in *ABC) bool { return in.B != ""}, +// func(in *ABC) bool { return in.C != 0 }, +// )...) +// return errs +// } +// +// It is not an error for the discriminatorValue to be unknown. That must be +// validated on its own. +func DiscriminatedUnion[T any, D ~string](_ context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj T, union *UnionMembership, discriminatorExtractor ExtractorFn[T, D], isSetFns ...ExtractorFn[T, bool]) (errs field.ErrorList) { + if len(union.members) != len(isSetFns) { + return field.ErrorList{ + field.InternalError(fldPath, + fmt.Errorf("number of extractors (%d) does not match number of union members (%d)", + len(isSetFns), len(union.members))), + } + } + var changed bool + discriminatorValue := discriminatorExtractor(obj) + if op.Type == operation.Update { + oldDiscriminatorValue := discriminatorExtractor(oldObj) + changed = discriminatorValue != oldDiscriminatorValue + } + + for i, fieldIsSet := range isSetFns { + member := union.members[i] + isDiscriminatedMember := string(discriminatorValue) == member.discriminatorValue + newIsSet := fieldIsSet(obj) + if op.Type == operation.Update && !changed { + oldIsSet := fieldIsSet(oldObj) + changed = changed || newIsSet != oldIsSet + } + if newIsSet && !isDiscriminatedMember { + errs = append(errs, field.Invalid(fldPath.Child(member.fieldName), "", + fmt.Sprintf("may only be specified when `%s` is %q", union.discriminatorName, member.discriminatorValue))) + } else if !newIsSet && isDiscriminatedMember { + errs = append(errs, field.Invalid(fldPath.Child(member.fieldName), "", + fmt.Sprintf("must be specified when `%s` is %q", union.discriminatorName, discriminatorValue))) + } + } + // If the union discriminator and membership is unchanged, we don't need to + // re-validate. + if op.Type == operation.Update && !changed { + return nil + } + return errs +} + +type member struct { + fieldName, discriminatorValue string +} + +// UnionMembership represents an ordered list of field union memberships. +type UnionMembership struct { + discriminatorName string + members []member +} + +// NewUnionMembership returns a new UnionMembership for the given list of members. +// +// Each member is a [2]string to provide a fieldName and discriminatorValue pair, where +// [0] identifies the field name and [1] identifies the union member Name. +// +// Field names must be unique. +func NewUnionMembership(member ...[2]string) *UnionMembership { + return NewDiscriminatedUnionMembership("", member...) +} + +// NewDiscriminatedUnionMembership returns a new UnionMembership for the given discriminator field and list of members. +// members are provided in the same way as for NewUnionMembership. +func NewDiscriminatedUnionMembership(discriminatorFieldName string, members ...[2]string) *UnionMembership { + u := &UnionMembership{} + u.discriminatorName = discriminatorFieldName + for _, fieldName := range members { + u.members = append(u.members, member{fieldName: fieldName[0], discriminatorValue: fieldName[1]}) + } + return u +} + +// allFields returns a string listing all the field names of the member of a union for use in error reporting. +func (u UnionMembership) allFields() []string { + memberNames := make([]string, 0, len(u.members)) + for _, f := range u.members { + memberNames = append(memberNames, fmt.Sprintf("`%s`", f.fieldName)) + } + return memberNames +} + +func unionValidate[T any](op operation.Operation, fldPath *field.Path, + obj, oldObj T, union *UnionMembership, options UnionValidationOptions, isSetFns ...ExtractorFn[T, bool], +) field.ErrorList { + if len(union.members) != len(isSetFns) { + return field.ErrorList{ + field.InternalError(fldPath, + fmt.Errorf("number of extractors (%d) does not match number of union members (%d)", + len(isSetFns), len(union.members))), + } + } + + var specifiedFields []string + var changed bool + for i, fieldIsSet := range isSetFns { + newIsSet := fieldIsSet(obj) + if op.Type == operation.Update && !changed { + oldIsSet := fieldIsSet(oldObj) + changed = changed || newIsSet != oldIsSet + } + if newIsSet { + specifiedFields = append(specifiedFields, union.members[i].fieldName) + } + } + + // If the union membership is unchanged, we don't need to re-validate. + if op.Type == operation.Update && !changed { + return nil + } + + var errs field.ErrorList + + if len(specifiedFields) > 1 && options.ErrorForMultiple != nil { + errs = append(errs, options.ErrorForMultiple(fldPath, specifiedFields, union.allFields())) + } + + if len(specifiedFields) == 0 && options.ErrorForEmpty != nil { + errs = append(errs, options.ErrorForEmpty(fldPath, union.allFields())) + } + + return errs +} diff --git a/staging/src/k8s.io/code-generator/cmd/validation-gen/validators/union.go b/staging/src/k8s.io/code-generator/cmd/validation-gen/validators/union.go new file mode 100644 index 00000000000..ab80d69dfab --- /dev/null +++ b/staging/src/k8s.io/code-generator/cmd/validation-gen/validators/union.go @@ -0,0 +1,497 @@ +/* +Copyright 2021 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package validators + +import ( + "encoding/json" + "fmt" + "regexp" + "slices" + "strings" + + "k8s.io/apimachinery/pkg/util/sets" + "k8s.io/code-generator/cmd/validation-gen/util" + "k8s.io/gengo/v2/codetags" + "k8s.io/gengo/v2/parser/tags" + "k8s.io/gengo/v2/types" +) + +var discriminatedUnionValidator = types.Name{Package: libValidationPkg, Name: "DiscriminatedUnion"} +var unionValidator = types.Name{Package: libValidationPkg, Name: "Union"} + +var newDiscriminatedUnionMembership = types.Name{Package: libValidationPkg, Name: "NewDiscriminatedUnionMembership"} +var newUnionMembership = types.Name{Package: libValidationPkg, Name: "NewUnionMembership"} +var unionVariablePrefix = "unionMembershipFor" + +func init() { + // Unions are comprised of multiple tags that need to share information. + // For field-based unions: tags are on struct fields, validation is on the struct + // For item-based unions: tags are on list items (via +k8s:item), validation is on the list + + // "shared" maps from field path strings (key) to union definitions (value) + // key examples: + // - struct union: "MyStruct" (validation on the struct type) + // - list union: "Pipeline.Tasks" (validation on the list field) + shared := map[string]unions{} + RegisterTypeValidator(unionTypeOrFieldValidator{shared}) + RegisterFieldValidator(unionTypeOrFieldValidator{shared}) + RegisterTagValidator(unionDiscriminatorTagValidator{shared}) + RegisterTagValidator(unionMemberTagValidator{shared}) +} + +type unionTypeOrFieldValidator struct { + shared map[string]unions +} + +func (unionTypeOrFieldValidator) Init(_ Config) {} + +func (unionTypeOrFieldValidator) Name() string { + return "unionTypeOrFieldValidator" +} + +func (utfv unionTypeOrFieldValidator) GetValidations(context Context) (Validations, error) { + // TODO: Add support for map items once map item validation is implemented + + // Extract the most concrete type possible. + if k := util.NonPointer(util.NativeType(context.Type)).Kind; k != types.Struct && k != types.Slice { + return Validations{}, nil + } + + unions := utfv.shared[context.Path.String()] + if len(unions) == 0 { + return Validations{}, nil + } + + return processUnionValidations(context, unions, unionVariablePrefix, + unionMemberTagName, unionValidator, discriminatedUnionValidator) +} + +func toSliceAny[T any](t []T) []any { + result := make([]any, len(t)) + for i, v := range t { + result[i] = v + } + return result +} + +const ( + unionDiscriminatorTagName = "k8s:unionDiscriminator" + unionMemberTagName = "k8s:unionMember" +) + +type unionDiscriminatorTagValidator struct { + shared map[string]unions +} + +func (unionDiscriminatorTagValidator) Init(_ Config) {} + +func (unionDiscriminatorTagValidator) TagName() string { + return unionDiscriminatorTagName +} + +// Shared between unionDiscriminatorTagValidator and unionMemberTagValidator. +var unionTagValidScopes = sets.New(ScopeField, ScopeListVal) + +func (unionDiscriminatorTagValidator) ValidScopes() sets.Set[Scope] { + return unionTagValidScopes +} + +func (udtv unionDiscriminatorTagValidator) GetValidations(context Context, tag codetags.Tag) (Validations, error) { + err := processDiscriminatorValidations(udtv.shared, context, tag) + if err != nil { + return Validations{}, err + } + // This tag does not actually emit any validations, it just accumulates + // information. The validation is done by the unionTypeOrFieldValidator. + return Validations{}, nil +} + +func (udtv unionDiscriminatorTagValidator) Docs() TagDoc { + return TagDoc{ + Tag: udtv.TagName(), + Scopes: udtv.ValidScopes().UnsortedList(), + Description: "Indicates that this field is the discriminator for a union.", + Args: []TagArgDoc{{ + Name: "union", + Description: "", + Docs: "the name of the union, if more than one exists", + Type: codetags.ArgTypeString, + }}, + } +} + +type unionMemberTagValidator struct { + shared map[string]unions +} + +func (unionMemberTagValidator) Init(_ Config) {} + +func (unionMemberTagValidator) TagName() string { + return unionMemberTagName +} + +func (unionMemberTagValidator) ValidScopes() sets.Set[Scope] { + return unionTagValidScopes +} + +func (umtv unionMemberTagValidator) GetValidations(context Context, tag codetags.Tag) (Validations, error) { + err := processMemberValidations(umtv.shared, context, tag) + if err != nil { + return Validations{}, err + } + // This tag does not actually emit any validations, it just accumulates + // information. The validation is done by the unionTypeOrFieldValidator. + return Validations{}, nil +} + +func (umtv unionMemberTagValidator) Docs() TagDoc { + return TagDoc{ + Tag: umtv.TagName(), + Scopes: umtv.ValidScopes().UnsortedList(), + Description: "Indicates that this field is a member of a union.", + Args: []TagArgDoc{{ + Name: "union", + Description: "", + Docs: "the name of the union, if more than one exists", + Type: codetags.ArgTypeString, + }, { + Name: "memberName", + Description: "", + Docs: "the discriminator value for this member", + Default: "the field's name", + Type: codetags.ArgTypeString, + }}, + } +} + +// union defines how a union validation will be generated, based +// on +k8s:unionMember and +k8s:unionDiscriminator tags found in a go struct. +type union struct { + // fields provides field information about all the members of the union. + // Each item provides a fieldName and memberName pair, where [0] identifies + // the field name and [1] identifies the union member Name. fields is index + // aligned with fieldMembers. + // If member name is not set, it defaults to the go struct field name. + fields [][2]string + // fieldMembers describes all the members of the union. + fieldMembers []*types.Member + + // discriminator is the name of the discriminator field + discriminator *string + // discriminatorMember describes the discriminator field. + discriminatorMember *types.Member + + // itemMatchers stores matcher criteria for list item unions. + // key is the virtual field path (eg: "/Pipeline.Tasks[{"name": "succeeded"}]"), + // value is the parsed matcher map (eg: {"name": "succeeded"}). + // Represents union members that are list items matching specific criteria + itemMatchers map[string]map[string]any +} + +// unions represents all the unions for a go struct. +type unions map[string]*union + +// newUnion initializes a new union instance +func newUnion() *union { + return &union{ + fields: make([][2]string, 0), + fieldMembers: make([]*types.Member, 0), + itemMatchers: make(map[string]map[string]any), + } +} + +// getOrCreate gets a union by name, or initializes a new union by the given name. +func (us unions) getOrCreate(name string) *union { + var u *union + var ok bool + if u, ok = us[name]; !ok { + u = newUnion() + us[name] = u + } + return u +} + +func processUnionValidations(context Context, unions unions, varPrefix string, + tagName string, undiscriminatedValidator types.Name, discriminatedValidator types.Name, +) (Validations, error) { + result := Validations{} + + // Sort the keys for stable output. + keys := make([]string, 0, len(unions)) + for k := range unions { + keys = append(keys, k) + } + slices.Sort(keys) + for _, unionName := range keys { + u := unions[unionName] + if len(u.fieldMembers) > 0 || u.discriminator != nil || len(u.itemMatchers) > 0 { + if len(u.fieldMembers) > 0 && len(u.itemMatchers) > 0 { + return Validations{}, fmt.Errorf("cannot have both field members and item matchers") + } + nativeType := util.NonPointer(util.NativeType(context.Type)) + if nativeType.Kind == types.Struct && len(u.itemMatchers) > 0 { + return Validations{}, fmt.Errorf("struct type cannot have item matchers") + } + if nativeType.Kind == types.Slice && len(u.fieldMembers) > 0 { + return Validations{}, fmt.Errorf("slice type cannot have field members") + } + + // TODO: Avoid the "local" here. This was added to to avoid errors caused when the package is an empty string. + // The correct package would be the output package but is not known here. This does not show up in generated code. + // TODO: Append a consistent hash suffix to avoid generated name conflicts? + varBaseName := sanitizeName(context.Path.String() + "_" + unionName) // unionName can be "" + supportVarName := PrivateVar{Name: varPrefix + "_" + varBaseName, Package: "local"} + + var extractorArgs []any + ptrType := types.PointerTo(context.Type) + + // Handle field unions + for _, member := range u.fieldMembers { + extractor := createMemberExtractor(ptrType, member) + extractorArgs = append(extractorArgs, extractor) + } + + // Handle list item unions for lists + if nativeType.Kind == types.Slice && len(u.itemMatchers) > 0 { + elemType := util.NonPointer(nativeType.Elem) + + // Sort matcher paths for stable output + matcherPaths := make([]string, 0, len(u.itemMatchers)) + for path := range u.itemMatchers { + matcherPaths = append(matcherPaths, path) + } + slices.Sort(matcherPaths) + + for _, fullPath := range matcherPaths { + matcher := u.itemMatchers[fullPath] + extractor, err := createItemExtractor(context.Type, elemType, matcher) + if err != nil { + return Validations{}, err + } + extractorArgs = append(extractorArgs, extractor) + } + } + + if u.discriminator != nil { + supportVar := Variable(supportVarName, + Function(tagName, DefaultFlags, newDiscriminatedUnionMembership, + append([]any{*u.discriminator}, toSliceAny(getDisplayFields(u, context))...)...)) + result.Variables = append(result.Variables, supportVar) + + discriminatorExtractor := FunctionLiteral{ + Parameters: []ParamResult{{Name: "obj", Type: ptrType}}, + Results: []ParamResult{{Type: types.String}}, + Body: fmt.Sprintf("if obj == nil {return \"\"}; return string(obj.%s)", u.discriminatorMember.Name), // Cast to string + } + + extraArgs := append([]any{supportVarName, discriminatorExtractor}, extractorArgs...) + fn := Function(tagName, DefaultFlags, discriminatedValidator, extraArgs...) + result.Functions = append(result.Functions, fn) + } else { + supportVar := Variable(supportVarName, Function(tagName, DefaultFlags, newUnionMembership, toSliceAny(getDisplayFields(u, context))...)) + result.Variables = append(result.Variables, supportVar) + + extraArgs := append([]any{supportVarName}, extractorArgs...) + fn := Function(tagName, DefaultFlags, undiscriminatedValidator, extraArgs...) + result.Functions = append(result.Functions, fn) + } + } + } + + return result, nil +} + +func createMemberExtractor(ptrType *types.Type, member *types.Member) FunctionLiteral { + extractor := FunctionLiteral{ + Parameters: []ParamResult{{Name: "obj", Type: ptrType}}, + Results: []ParamResult{{Type: types.Bool}}, + } + nt := util.NativeType(member.Type) + switch nt.Kind { + case types.Pointer, types.Map, types.Slice: + extractor.Body = fmt.Sprintf("if obj == nil {return false}; return obj.%s != nil", member.Name) + case types.Builtin: + extractor.Body = fmt.Sprintf("if obj == nil {return false}; var z %s; return obj.%s != z", member.Type, member.Name) + default: + // This should be caught before we get here, but JIC. + extractor.Body = fmt.Sprintf("if obj == nil {return false}; return false /* unsupported union member kind: %s */", nt.Kind) + } + return extractor +} + +// createItemExtractor creates an extractor function for list item union members. +// It generates code that loops through the list to check if an item matching the criteria exists. +func createItemExtractor(listType *types.Type, elemType *types.Type, matcher map[string]any) (FunctionLiteral, error) { + var criteria []keyValuePair + for key, value := range matcher { + criteria = append(criteria, keyValuePair{ + key: key, + value: fmt.Sprint(value), + }) + } + + // Sort for stable output. + slices.SortFunc(criteria, func(a, b keyValuePair) int { + return strings.Compare(a.key, b.key) + }) + + condition, err := buildMatchConditions(elemType, criteria, "list[i]") + if err != nil { + return FunctionLiteral{}, err + } + + extractor := FunctionLiteral{ + Parameters: []ParamResult{{Name: "list", Type: listType}}, + Results: []ParamResult{{Type: types.Bool}}, + Body: fmt.Sprintf(`for i := range list { + if %s { + return true + } +} +return false`, condition), + } + + return extractor, nil +} + +func processDiscriminatorValidations(shared map[string]unions, context Context, tag codetags.Tag) error { + // This tag can apply to value and pointer fields, as well as typedefs + // (which should never be pointers). We need to check the concrete type. + if t := util.NonPointer(util.NativeType(context.Type)); t != types.String { + return fmt.Errorf("can only be used on string types (%s)", rootTypeString(context.Type, t)) + } + if shared[context.ParentPath.String()] == nil { + shared[context.ParentPath.String()] = unions{} + } + unionArg, _ := tag.NamedArg("union") // optional + u := shared[context.ParentPath.String()].getOrCreate(unionArg.Value) + + var discriminatorFieldName string + if jsonAnnotation, ok := tags.LookupJSON(*context.Member); ok { + discriminatorFieldName = jsonAnnotation.Name + u.discriminator = &discriminatorFieldName + u.discriminatorMember = context.Member + } + + return nil +} + +func processMemberValidations(shared map[string]unions, context Context, tag codetags.Tag) error { + var fieldName string + var unionArg codetags.Arg + + unionArg, _ = tag.NamedArg("union") // optional + + if context.Scope == ScopeListVal { + if context.Path == nil { + return fmt.Errorf("no path for list val union member") + } + fieldName = context.Path.String() // eg: "/Pipeline.Tasks[{"name": "succeeded"}]" + } else { + nt := util.NativeType(context.Member.Type) + switch nt.Kind { + case types.Pointer, types.Map, types.Slice, types.Builtin: + // OK + default: + // In particular non-pointer structs are not supported. + return fmt.Errorf("can only be used on nilable and primitive types (%s)", nt.Kind) + } + + jsonTag, ok := tags.LookupJSON(*context.Member) + if !ok { + return fmt.Errorf("field %q is a union member but has no JSON struct field tag", context.Member) + } + fieldName = jsonTag.Name + if len(fieldName) == 0 { + return fmt.Errorf("field %q is a union member but has no JSON name", context.Member) + } + } + + if shared[context.ParentPath.String()] == nil { + shared[context.ParentPath.String()] = unions{} + } + + var memberName string + if memberNameArg, ok := tag.NamedArg("memberName"); ok { // optional + memberName = memberNameArg.Value + } else if context.Scope != ScopeListVal { + memberName = context.Member.Name // default + } + + u := shared[context.ParentPath.String()].getOrCreate(unionArg.Value) + u.fields = append(u.fields, [2]string{fieldName, memberName}) + + if context.Scope == ScopeListVal { + matcher, err := extractMatcherFromPath(fieldName) + if err != nil { + return fmt.Errorf("failed to extract matcher from path %s: %w", fieldName, err) + } + u.itemMatchers[fieldName] = matcher + } else { + u.fieldMembers = append(u.fieldMembers, context.Member) + } + + return nil +} + +// getDisplayFields formats union field names for user-friendly error messages. +// For list item unions, it converts paths like "/Pipeline.Tasks[{\"name\": \"succeeded\"}]" +// to readable formats like "Tasks[{\"name\": \"succeeded\"}]". +func getDisplayFields(u *union, context Context) [][2]string { + displayFields := make([][2]string, len(u.fields)) + listFieldName := context.Path.String() + pathParts := strings.Split(listFieldName, ".") + if len(pathParts) > 0 { + listFieldName = pathParts[len(pathParts)-1] + } + for i, f := range u.fields { + fieldName := f[0] + memberName := f[1] + if _, isItem := u.itemMatchers[fieldName]; isItem { + // Extract the JSON part from the input + bracketIndex := strings.Index(fieldName, "[") + if bracketIndex != -1 { + jsonPart := fieldName[bracketIndex:] + fieldName = listFieldName + jsonPart + } + } + displayFields[i] = [2]string{fieldName, memberName} + } + return displayFields +} + +// sanitizeName converts a string into a valid Go identifier +func sanitizeName(name string) string { + name = strings.ReplaceAll(name, ".", "_") + re := regexp.MustCompile(`[^a-zA-Z0-9_]`) + return re.ReplaceAllString(name, "_") +} + +// extractMatcherFromPath extracts the matcher criteria from a path like "Pipeline.Tasks[{"name": "succeeded"}]" +func extractMatcherFromPath(path string) (map[string]any, error) { + re := regexp.MustCompile(`\[({.*?})\]`) + matches := re.FindStringSubmatch(path) + if len(matches) < 2 { + return nil, fmt.Errorf("no matcher criteria found in path") + } + + var matcher map[string]any + if err := json.Unmarshal([]byte(matches[1]), &matcher); err != nil { + return nil, fmt.Errorf("failed to parse matcher JSON: %w", err) + } + return matcher, nil +}