@@ -6,13 +6,26 @@ import (
66 "github.com/speakeasy-api/openapi/openapi"
77)
88
9+ // tagRenameResult holds separate rename maps for the existing (merged) document
10+ // and the incoming document. Keeping them separate allows case-insensitive
11+ // operation-level tag renaming without ambiguity — each map has at most one
12+ // entry per case-insensitive tag name.
13+ type tagRenameResult struct {
14+ existingRenames map [string ]string // renames for operations already in mergedDoc
15+ incomingRenames map [string ]string // renames for operations in the incoming doc
16+ }
17+
918// mergeTagsWithState performs case-insensitive tag merging with content-aware disambiguation.
10- // It returns a map of old tag name → new tag name for updating operation-level tag references.
11- func mergeTagsWithState (state * mergeState , mergedDoc , doc * openapi.OpenAPI , docNamespace string , docCounter int ) map [string ]string {
12- tagRenames := make (map [string ]string )
19+ // It returns separate rename maps for updating operation-level tag references in the
20+ // existing merged document and the incoming document.
21+ func mergeTagsWithState (state * mergeState , mergedDoc , doc * openapi.OpenAPI , docNamespace string , docCounter int ) tagRenameResult {
22+ result := tagRenameResult {
23+ existingRenames : make (map [string ]string ),
24+ incomingRenames : make (map [string ]string ),
25+ }
1326
1427 if doc .Tags == nil {
15- return tagRenames
28+ return result
1629 }
1730
1831 if mergedDoc .Tags == nil {
@@ -53,7 +66,7 @@ func mergeTagsWithState(state *mergeState, mergedDoc, doc *openapi.OpenAPI, docN
5366 mergedDoc .Tags [matchIdx ] = newTag
5467 // Record rename if the name changed (e.g. casing difference)
5568 if oldName != newTag .Name {
56- tagRenames [oldName ] = newTag .Name
69+ result . existingRenames [oldName ] = newTag .Name
5770 }
5871 // Update the tracker entry
5972 state.tagTracker [key ][matchEntry ] = tagEntry {
@@ -70,7 +83,7 @@ func mergeTagsWithState(state *mergeState, mergedDoc, doc *openapi.OpenAPI, docN
7083 oldName := existingTag .Name
7184 newName := oldName + "_" + existingSuffix
7285 existingTag .Name = newName
73- tagRenames [oldName ] = newName
86+ result . existingRenames [oldName ] = newName
7487 state.tagTracker [key ][matchEntry ] = tagEntry {
7588 currentName : newName ,
7689 namespace : entries [matchEntry ].namespace ,
@@ -82,7 +95,7 @@ func mergeTagsWithState(state *mergeState, mergedDoc, doc *openapi.OpenAPI, docN
8295 newSuffix := disambiguatingSuffix (docNamespace , docCounter )
8396 oldNewTagName := newTag .Name
8497 newTag .Name = oldNewTagName + "_" + newSuffix
85- tagRenames [oldNewTagName ] = newTag .Name
98+ result . incomingRenames [oldNewTagName ] = newTag .Name
8699
87100 mergedDoc .Tags = append (mergedDoc .Tags , newTag )
88101 state .tagTracker [key ] = append (state .tagTracker [key ], tagEntry {
@@ -93,7 +106,7 @@ func mergeTagsWithState(state *mergeState, mergedDoc, doc *openapi.OpenAPI, docN
93106 }
94107 }
95108
96- return tagRenames
109+ return result
97110}
98111
99112// findTagInMergedDoc locates the tag in mergedDoc.Tags that corresponds to one
@@ -174,19 +187,25 @@ func ptrStringEqual(a, b *string) bool {
174187}
175188
176189// updateOperationTagRefs walks all operations in the document and updates
177- // tag name references according to the renames map.
190+ // tag name references according to the renames map (case-insensitive) .
178191func updateOperationTagRefs (doc * openapi.OpenAPI , renames map [string ]string ) {
179192 if len (renames ) == 0 {
180193 return
181194 }
182195
196+ // Build case-insensitive lookup once and reuse for all operations.
197+ ciRenames := make (map [string ]string , len (renames ))
198+ for oldName , newName := range renames {
199+ ciRenames [strings .ToLower (oldName )] = newName
200+ }
201+
183202 if doc .Paths != nil {
184203 for _ , pathItem := range doc .Paths .All () {
185204 if pathItem == nil || pathItem .Object == nil {
186205 continue
187206 }
188207 for _ , op := range pathItem .Object .All () {
189- renameOpTags (op , renames )
208+ renameOpTags (op , ciRenames )
190209 }
191210 }
192211 }
@@ -197,20 +216,73 @@ func updateOperationTagRefs(doc *openapi.OpenAPI, renames map[string]string) {
197216 continue
198217 }
199218 for _ , op := range pathItem .Object .All () {
200- renameOpTags (op , renames )
219+ renameOpTags (op , ciRenames )
201220 }
202221 }
203222 }
204223}
205224
206- // renameOpTags updates the Tags slice of a single operation.
207- func renameOpTags (op * openapi.Operation , renames map [string ]string ) {
225+ // renameOpTags updates the Tags slice of a single operation using a
226+ // pre-built case-insensitive rename map (lowercase keys → new names).
227+ func renameOpTags (op * openapi.Operation , ciRenames map [string ]string ) {
208228 if op == nil {
209229 return
210230 }
231+
211232 for i , tag := range op .Tags {
212- if newName , ok := renames [ tag ]; ok {
233+ if newName , ok := ciRenames [ strings . ToLower ( tag ) ]; ok {
213234 op .Tags [i ] = newName
214235 }
215236 }
216237}
238+
239+ // normalizeOperationTags is a post-merge pass that ensures all operation-level
240+ // tag references use the same casing as the document-level tag definitions.
241+ // This catches cases where operations reference tags with a casing that wasn't
242+ // directly involved in a rename (e.g. "PETS" when the rename only covered
243+ // "Pets" → "pets"), or where tags are used only in operations without any
244+ // document-level definition.
245+ func normalizeOperationTags (doc * openapi.OpenAPI ) {
246+ // Build canonical name map from document-level tags.
247+ canonical := make (map [string ]string , len (doc .Tags ))
248+ for _ , tag := range doc .Tags {
249+ canonical [strings .ToLower (tag .Name )] = tag .Name
250+ }
251+
252+ normalize := func (op * openapi.Operation ) {
253+ if op == nil {
254+ return
255+ }
256+ for i , tag := range op .Tags {
257+ key := strings .ToLower (tag )
258+ if chosen , ok := canonical [key ]; ok {
259+ op .Tags [i ] = chosen
260+ } else {
261+ // Tag not defined at document level — use first occurrence as canonical.
262+ canonical [key ] = tag
263+ }
264+ }
265+ }
266+
267+ if doc .Paths != nil {
268+ for _ , pathItem := range doc .Paths .All () {
269+ if pathItem == nil || pathItem .Object == nil {
270+ continue
271+ }
272+ for _ , op := range pathItem .Object .All () {
273+ normalize (op )
274+ }
275+ }
276+ }
277+
278+ if doc .Webhooks != nil {
279+ for _ , pathItem := range doc .Webhooks .All () {
280+ if pathItem == nil || pathItem .Object == nil {
281+ continue
282+ }
283+ for _ , op := range pathItem .Object .All () {
284+ normalize (op )
285+ }
286+ }
287+ }
288+ }
0 commit comments