cricinfo-cli-go 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/AGENTS.md +63 -0
  2. package/CONTRIBUTORS.md +75 -0
  3. package/LICENSE +21 -0
  4. package/Makefile +131 -0
  5. package/README.md +130 -0
  6. package/bin/cricinfo.js +44 -0
  7. package/cmd/cricinfo/main.go +15 -0
  8. package/go.mod +10 -0
  9. package/go.sum +10 -0
  10. package/internal/app/app.go +11 -0
  11. package/internal/app/app_test.go +122 -0
  12. package/internal/buildinfo/buildinfo.go +16 -0
  13. package/internal/cli/analysis.go +262 -0
  14. package/internal/cli/analysis_test.go +175 -0
  15. package/internal/cli/competitions.go +154 -0
  16. package/internal/cli/competitions_test.go +165 -0
  17. package/internal/cli/leagues.go +297 -0
  18. package/internal/cli/leagues_test.go +194 -0
  19. package/internal/cli/matches.go +403 -0
  20. package/internal/cli/matches_test.go +413 -0
  21. package/internal/cli/players.go +263 -0
  22. package/internal/cli/players_test.go +384 -0
  23. package/internal/cli/root.go +141 -0
  24. package/internal/cli/search.go +119 -0
  25. package/internal/cli/teams.go +214 -0
  26. package/internal/cli/teams_test.go +192 -0
  27. package/internal/cricinfo/analysis.go +1401 -0
  28. package/internal/cricinfo/analysis_phase15_test.go +267 -0
  29. package/internal/cricinfo/client.go +471 -0
  30. package/internal/cricinfo/client_test.go +280 -0
  31. package/internal/cricinfo/cmd/fixture-refresh/main.go +145 -0
  32. package/internal/cricinfo/competitions.go +405 -0
  33. package/internal/cricinfo/competitions_phase13_test.go +234 -0
  34. package/internal/cricinfo/coverage_ledger.go +122 -0
  35. package/internal/cricinfo/coverage_ledger_test.go +253 -0
  36. package/internal/cricinfo/decode.go +115 -0
  37. package/internal/cricinfo/decode_test.go +100 -0
  38. package/internal/cricinfo/entity_index.go +618 -0
  39. package/internal/cricinfo/entity_index_test.go +175 -0
  40. package/internal/cricinfo/fixture_matrix.go +243 -0
  41. package/internal/cricinfo/fixture_matrix_test.go +49 -0
  42. package/internal/cricinfo/fixtures_test.go +264 -0
  43. package/internal/cricinfo/historical_hydration.go +1641 -0
  44. package/internal/cricinfo/historical_phase14_test.go +542 -0
  45. package/internal/cricinfo/leagues.go +1210 -0
  46. package/internal/cricinfo/leagues_phase12_test.go +324 -0
  47. package/internal/cricinfo/live_leagues_test.go +169 -0
  48. package/internal/cricinfo/live_matches_test.go +203 -0
  49. package/internal/cricinfo/live_matrix_test.go +118 -0
  50. package/internal/cricinfo/live_players_test.go +122 -0
  51. package/internal/cricinfo/live_search_test.go +86 -0
  52. package/internal/cricinfo/live_smoke_test.go +213 -0
  53. package/internal/cricinfo/live_teams_test.go +104 -0
  54. package/internal/cricinfo/matches.go +1508 -0
  55. package/internal/cricinfo/matches_phase7_test.go +207 -0
  56. package/internal/cricinfo/matches_phase9_test.go +253 -0
  57. package/internal/cricinfo/normalize_entities.go +1727 -0
  58. package/internal/cricinfo/normalize_leagues.go +346 -0
  59. package/internal/cricinfo/players.go +1332 -0
  60. package/internal/cricinfo/players_phase10_test.go +174 -0
  61. package/internal/cricinfo/players_phase11_test.go +373 -0
  62. package/internal/cricinfo/render_contract.go +1088 -0
  63. package/internal/cricinfo/render_phase4_test.go +633 -0
  64. package/internal/cricinfo/renderer.go +1689 -0
  65. package/internal/cricinfo/resolver.go +813 -0
  66. package/internal/cricinfo/resolver_test.go +244 -0
  67. package/internal/cricinfo/teams.go +603 -0
  68. package/internal/cricinfo/teams_phase8_test.go +231 -0
  69. package/internal/cricinfo/testdata/fixtures/README.md +43 -0
  70. package/internal/cricinfo/testdata/fixtures/aux-competition-metadata/broadcasts.json +11 -0
  71. package/internal/cricinfo/testdata/fixtures/aux-competition-metadata/officials.json +150 -0
  72. package/internal/cricinfo/testdata/fixtures/details-plays/detail-110.json +157 -0
  73. package/internal/cricinfo/testdata/fixtures/details-plays/detail-52545007.json +145 -0
  74. package/internal/cricinfo/testdata/fixtures/details-plays/detail-52559021.json +143 -0
  75. package/internal/cricinfo/testdata/fixtures/details-plays/plays.json +15 -0
  76. package/internal/cricinfo/testdata/fixtures/endpoint-matrix.tsv +19 -0
  77. package/internal/cricinfo/testdata/fixtures/innings-fow-partnerships/fow-1.json +12 -0
  78. package/internal/cricinfo/testdata/fixtures/innings-fow-partnerships/fow.json +42 -0
  79. package/internal/cricinfo/testdata/fixtures/innings-fow-partnerships/innings-1-2.json +38 -0
  80. package/internal/cricinfo/testdata/fixtures/innings-fow-partnerships/partnership-1.json +31 -0
  81. package/internal/cricinfo/testdata/fixtures/innings-fow-partnerships/partnerships.json +42 -0
  82. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/calendar-offdays.json +20 -0
  83. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/calendar-ondays.json +21 -0
  84. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/calendar.json +14 -0
  85. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/season-2025.json +13 -0
  86. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/season-group-1.json +13 -0
  87. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/season-groups.json +11 -0
  88. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/season-type-1.json +13 -0
  89. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/season-types.json +11 -0
  90. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/seasons.json +30 -0
  91. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/standings-item-1.json +72 -0
  92. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/standings-root.json +3 -0
  93. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/standings.json +15 -0
  94. package/internal/cricinfo/testdata/fixtures/matches-competitions/competition.json +460 -0
  95. package/internal/cricinfo/testdata/fixtures/matches-competitions/event-1529474.json +86 -0
  96. package/internal/cricinfo/testdata/fixtures/matches-competitions/matchcards-1527966.json +368 -0
  97. package/internal/cricinfo/testdata/fixtures/matches-competitions/situation-1529474.json +10 -0
  98. package/internal/cricinfo/testdata/fixtures/players/athlete-1361257-statistics.json +126 -0
  99. package/internal/cricinfo/testdata/fixtures/players/athlete-1361257.json +113 -0
  100. package/internal/cricinfo/testdata/fixtures/players/roster-1361257-linescores-1-1-statistics-0.json +208 -0
  101. package/internal/cricinfo/testdata/fixtures/players/roster-1361257-linescores-1-2-statistics-0.json +252 -0
  102. package/internal/cricinfo/testdata/fixtures/players/roster-1361257-linescores.json +74 -0
  103. package/internal/cricinfo/testdata/fixtures/players/roster-1361257-statistics-0.json +1008 -0
  104. package/internal/cricinfo/testdata/fixtures/root-discovery/events.json +72 -0
  105. package/internal/cricinfo/testdata/fixtures/root-discovery/root.json +28 -0
  106. package/internal/cricinfo/testdata/fixtures/team-competitor/competitor-789643.json +40 -0
  107. package/internal/cricinfo/testdata/fixtures/team-competitor/leaders-789643.json +353 -0
  108. package/internal/cricinfo/testdata/fixtures/team-competitor/records-789643.json +91 -0
  109. package/internal/cricinfo/testdata/fixtures/team-competitor/roster-1147772-object.json +231 -0
  110. package/internal/cricinfo/testdata/fixtures/team-competitor/roster-1147772.json +235 -0
  111. package/internal/cricinfo/testdata/fixtures/team-competitor/roster-789643.json +322 -0
  112. package/internal/cricinfo/testdata/fixtures/team-competitor/scores-789643.json +19 -0
  113. package/internal/cricinfo/testdata/fixtures/team-competitor/statistics-789643.json +629 -0
  114. package/internal/cricinfo/testdata/fixtures/team-competitor/team-789643-athletes.json +7 -0
  115. package/internal/cricinfo/testdata/fixtures/team-competitor/team-789643.json +67 -0
  116. package/internal/cricinfo/testdata/golden/match-empty.golden +1 -0
  117. package/internal/cricinfo/testdata/golden/match-list.golden +2 -0
  118. package/internal/cricinfo/testdata/golden/match-partial.golden +3 -0
  119. package/internal/cricinfo/types.go +54 -0
  120. package/package.json +51 -0
  121. package/scripts/postinstall.js +153 -0
@@ -0,0 +1,603 @@
1
+ package cricinfo
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+ "strings"
7
+ )
8
+
9
+ // TeamServiceConfig configures team discovery and match-scoped team commands.
10
+ type TeamServiceConfig struct {
11
+ Client *Client
12
+ Resolver *Resolver
13
+ }
14
+
15
+ // TeamLookupOptions controls resolver-backed team lookup behavior.
16
+ type TeamLookupOptions struct {
17
+ LeagueID string
18
+ MatchQuery string
19
+ }
20
+
21
+ // TeamService implements domain-level team and competitor commands.
22
+ type TeamService struct {
23
+ client *Client
24
+ resolver *Resolver
25
+ ownsResolver bool
26
+ }
27
+
28
+ // NewTeamService builds a team service using default client/resolver when omitted.
29
+ func NewTeamService(cfg TeamServiceConfig) (*TeamService, error) {
30
+ client := cfg.Client
31
+ if client == nil {
32
+ var err error
33
+ client, err = NewClient(Config{})
34
+ if err != nil {
35
+ return nil, err
36
+ }
37
+ }
38
+
39
+ resolver := cfg.Resolver
40
+ ownsResolver := false
41
+ if resolver == nil {
42
+ var err error
43
+ resolver, err = NewResolver(ResolverConfig{Client: client})
44
+ if err != nil {
45
+ return nil, err
46
+ }
47
+ ownsResolver = true
48
+ }
49
+
50
+ return &TeamService{
51
+ client: client,
52
+ resolver: resolver,
53
+ ownsResolver: ownsResolver,
54
+ }, nil
55
+ }
56
+
57
+ // Close persists resolver cache when owned by this service.
58
+ func (s *TeamService) Close() error {
59
+ if !s.ownsResolver || s.resolver == nil {
60
+ return nil
61
+ }
62
+ return s.resolver.Close()
63
+ }
64
+
65
+ // Show resolves and returns one team summary, merging global team identity with match-scoped competitor fields when available.
66
+ func (s *TeamService) Show(ctx context.Context, teamQuery string, opts TeamLookupOptions) (NormalizedResult, error) {
67
+ lookup, passthrough := s.resolveTeamLookup(ctx, teamQuery, opts)
68
+ if passthrough != nil {
69
+ passthrough.Kind = EntityTeam
70
+ return *passthrough, nil
71
+ }
72
+
73
+ result := NewDataResult(EntityTeam, lookup.team)
74
+ if len(lookup.warnings) > 0 {
75
+ result = NewPartialResult(EntityTeam, lookup.team, lookup.warnings...)
76
+ }
77
+ if lookup.teamResolved != nil {
78
+ result.RequestedRef = lookup.teamResolved.RequestedRef
79
+ result.CanonicalRef = lookup.teamResolved.CanonicalRef
80
+ }
81
+ return result, nil
82
+ }
83
+
84
+ // Roster resolves and returns a team roster. Without --match it uses global team athletes; with --match it uses competitor roster.
85
+ func (s *TeamService) Roster(ctx context.Context, teamQuery string, opts TeamLookupOptions) (NormalizedResult, error) {
86
+ lookup, passthrough := s.resolveTeamLookup(ctx, teamQuery, opts)
87
+ if passthrough != nil {
88
+ passthrough.Kind = EntityTeamRoster
89
+ return *passthrough, nil
90
+ }
91
+
92
+ warnings := append([]string{}, lookup.warnings...)
93
+ useMatchScope := strings.TrimSpace(opts.MatchQuery) != "" && lookup.match != nil
94
+
95
+ rosterRef := ""
96
+ scope := TeamScopeGlobal
97
+ if useMatchScope {
98
+ rosterRef = nonEmpty(strings.TrimSpace(lookup.team.RosterRef), competitorSubresourceRef(*lookup.match, lookup.team.ID, "roster"))
99
+ scope = TeamScopeMatch
100
+ } else {
101
+ rosterRef = nonEmpty(extensionRef(lookup.team.Extensions, "athletes"), "/teams/"+strings.TrimSpace(lookup.team.ID)+"/athletes")
102
+ }
103
+
104
+ if strings.TrimSpace(rosterRef) == "" {
105
+ result := NormalizedResult{
106
+ Kind: EntityTeamRoster,
107
+ Status: ResultStatusEmpty,
108
+ Message: fmt.Sprintf("roster route unavailable for team %q", lookup.team.ID),
109
+ }
110
+ return result, nil
111
+ }
112
+
113
+ resolved, err := s.client.ResolveRefChain(ctx, rosterRef)
114
+ if err != nil {
115
+ return NewTransportErrorResult(EntityTeamRoster, rosterRef, err), nil
116
+ }
117
+
118
+ var entries []TeamRosterEntry
119
+ if scope == TeamScopeMatch {
120
+ entries, err = NormalizeTeamRosterEntries(resolved.Body, lookup.team, scope, lookup.match.ID)
121
+ } else {
122
+ entries, err = NormalizeTeamAthletePage(resolved.Body, lookup.team)
123
+ }
124
+ if err != nil {
125
+ return NormalizedResult{}, fmt.Errorf("normalize team roster %q: %w", resolved.CanonicalRef, err)
126
+ }
127
+
128
+ s.enrichRosterEntries(entries)
129
+
130
+ items := make([]any, 0, len(entries))
131
+ for _, entry := range entries {
132
+ items = append(items, entry)
133
+ }
134
+
135
+ result := NewListResult(EntityTeamRoster, items)
136
+ if len(warnings) > 0 {
137
+ result = NewPartialListResult(EntityTeamRoster, items, warnings...)
138
+ }
139
+ result.RequestedRef = resolved.RequestedRef
140
+ result.CanonicalRef = resolved.CanonicalRef
141
+ return result, nil
142
+ }
143
+
144
+ // Scores resolves and returns one match-scoped team score response.
145
+ func (s *TeamService) Scores(ctx context.Context, teamQuery string, opts TeamLookupOptions) (NormalizedResult, error) {
146
+ lookup, passthrough := s.resolveTeamLookup(ctx, teamQuery, opts)
147
+ if passthrough != nil {
148
+ passthrough.Kind = EntityTeamScore
149
+ return *passthrough, nil
150
+ }
151
+ if lookup.match == nil {
152
+ return matchScopeRequiredResult(EntityTeamScore), nil
153
+ }
154
+
155
+ scoreRef := nonEmpty(strings.TrimSpace(lookup.team.ScoreRef), competitorSubresourceRef(*lookup.match, lookup.team.ID, "scores"))
156
+ if scoreRef == "" {
157
+ return NormalizedResult{Kind: EntityTeamScore, Status: ResultStatusEmpty, Message: fmt.Sprintf("score route unavailable for team %q", lookup.team.ID)}, nil
158
+ }
159
+
160
+ resolved, err := s.client.ResolveRefChain(ctx, scoreRef)
161
+ if err != nil {
162
+ return NewTransportErrorResult(EntityTeamScore, scoreRef, err), nil
163
+ }
164
+
165
+ score, err := NormalizeTeamScore(resolved.Body, lookup.team, TeamScopeMatch, lookup.match.ID)
166
+ if err != nil {
167
+ return NormalizedResult{}, fmt.Errorf("normalize team score %q: %w", resolved.CanonicalRef, err)
168
+ }
169
+
170
+ result := NewDataResult(EntityTeamScore, score)
171
+ if len(lookup.warnings) > 0 {
172
+ result = NewPartialResult(EntityTeamScore, score, lookup.warnings...)
173
+ }
174
+ result.RequestedRef = resolved.RequestedRef
175
+ result.CanonicalRef = resolved.CanonicalRef
176
+ return result, nil
177
+ }
178
+
179
+ // Leaders resolves and returns one match-scoped team leaders payload.
180
+ func (s *TeamService) Leaders(ctx context.Context, teamQuery string, opts TeamLookupOptions) (NormalizedResult, error) {
181
+ lookup, passthrough := s.resolveTeamLookup(ctx, teamQuery, opts)
182
+ if passthrough != nil {
183
+ passthrough.Kind = EntityTeamLeaders
184
+ return *passthrough, nil
185
+ }
186
+ if lookup.match == nil {
187
+ return matchScopeRequiredResult(EntityTeamLeaders), nil
188
+ }
189
+
190
+ leadersRef := nonEmpty(strings.TrimSpace(lookup.team.LeadersRef), competitorSubresourceRef(*lookup.match, lookup.team.ID, "leaders"))
191
+ if leadersRef == "" {
192
+ return NormalizedResult{Kind: EntityTeamLeaders, Status: ResultStatusEmpty, Message: fmt.Sprintf("leaders route unavailable for team %q", lookup.team.ID)}, nil
193
+ }
194
+
195
+ resolved, err := s.client.ResolveRefChain(ctx, leadersRef)
196
+ if err != nil {
197
+ return NewTransportErrorResult(EntityTeamLeaders, leadersRef, err), nil
198
+ }
199
+
200
+ leaders, err := NormalizeTeamLeaders(resolved.Body, lookup.team, TeamScopeMatch, lookup.match.ID)
201
+ if err != nil {
202
+ return NormalizedResult{}, fmt.Errorf("normalize team leaders %q: %w", resolved.CanonicalRef, err)
203
+ }
204
+ leaders.TeamName = nonEmpty(leaders.TeamName, lookup.team.ShortName, lookup.team.Name)
205
+ leaders.Name = nonEmpty(leaders.Name, leaders.TeamName, "Leaders")
206
+ s.enrichTeamLeaders(ctx, leaders)
207
+
208
+ result := NewDataResult(EntityTeamLeaders, leaders)
209
+ if len(lookup.warnings) > 0 {
210
+ result = NewPartialResult(EntityTeamLeaders, leaders, lookup.warnings...)
211
+ }
212
+ result.RequestedRef = resolved.RequestedRef
213
+ result.CanonicalRef = resolved.CanonicalRef
214
+ return result, nil
215
+ }
216
+
217
+ // Statistics resolves and returns match-scoped team statistics categories.
218
+ func (s *TeamService) Statistics(ctx context.Context, teamQuery string, opts TeamLookupOptions) (NormalizedResult, error) {
219
+ lookup, passthrough := s.resolveTeamLookup(ctx, teamQuery, opts)
220
+ if passthrough != nil {
221
+ passthrough.Kind = EntityTeamStatistics
222
+ return *passthrough, nil
223
+ }
224
+ if lookup.match == nil {
225
+ return matchScopeRequiredResult(EntityTeamStatistics), nil
226
+ }
227
+
228
+ statisticsRef := nonEmpty(strings.TrimSpace(lookup.team.StatisticsRef), competitorSubresourceRef(*lookup.match, lookup.team.ID, "statistics"))
229
+ if statisticsRef == "" {
230
+ return NormalizedResult{Kind: EntityTeamStatistics, Status: ResultStatusEmpty, Message: fmt.Sprintf("statistics route unavailable for team %q", lookup.team.ID)}, nil
231
+ }
232
+
233
+ resolved, err := s.client.ResolveRefChain(ctx, statisticsRef)
234
+ if err != nil {
235
+ return NewTransportErrorResult(EntityTeamStatistics, statisticsRef, err), nil
236
+ }
237
+
238
+ categories, err := NormalizeStatCategories(resolved.Body)
239
+ if err != nil {
240
+ return NormalizedResult{}, fmt.Errorf("normalize team statistics %q: %w", resolved.CanonicalRef, err)
241
+ }
242
+
243
+ items := make([]any, 0, len(categories))
244
+ for _, category := range categories {
245
+ items = append(items, category)
246
+ }
247
+
248
+ result := NewListResult(EntityTeamStatistics, items)
249
+ if len(lookup.warnings) > 0 {
250
+ result = NewPartialListResult(EntityTeamStatistics, items, lookup.warnings...)
251
+ }
252
+ result.RequestedRef = resolved.RequestedRef
253
+ result.CanonicalRef = resolved.CanonicalRef
254
+ return result, nil
255
+ }
256
+
257
+ // Records resolves and returns match-scoped team records categories.
258
+ func (s *TeamService) Records(ctx context.Context, teamQuery string, opts TeamLookupOptions) (NormalizedResult, error) {
259
+ lookup, passthrough := s.resolveTeamLookup(ctx, teamQuery, opts)
260
+ if passthrough != nil {
261
+ passthrough.Kind = EntityTeamRecords
262
+ return *passthrough, nil
263
+ }
264
+ if lookup.match == nil {
265
+ return matchScopeRequiredResult(EntityTeamRecords), nil
266
+ }
267
+
268
+ recordsRef := nonEmpty(strings.TrimSpace(lookup.team.RecordRef), competitorSubresourceRef(*lookup.match, lookup.team.ID, "records"))
269
+ if recordsRef == "" {
270
+ return NormalizedResult{Kind: EntityTeamRecords, Status: ResultStatusEmpty, Message: fmt.Sprintf("records route unavailable for team %q", lookup.team.ID)}, nil
271
+ }
272
+
273
+ resolved, err := s.client.ResolveRefChain(ctx, recordsRef)
274
+ if err != nil {
275
+ return NewTransportErrorResult(EntityTeamRecords, recordsRef, err), nil
276
+ }
277
+
278
+ categories, err := NormalizeTeamRecordCategories(resolved.Body)
279
+ if err != nil {
280
+ return NormalizedResult{}, fmt.Errorf("normalize team records %q: %w", resolved.CanonicalRef, err)
281
+ }
282
+
283
+ items := make([]any, 0, len(categories))
284
+ for _, category := range categories {
285
+ items = append(items, category)
286
+ }
287
+
288
+ result := NewListResult(EntityTeamRecords, items)
289
+ if len(lookup.warnings) > 0 {
290
+ result = NewPartialListResult(EntityTeamRecords, items, lookup.warnings...)
291
+ }
292
+ result.RequestedRef = resolved.RequestedRef
293
+ result.CanonicalRef = resolved.CanonicalRef
294
+ return result, nil
295
+ }
296
+
297
+ type teamLookup struct {
298
+ entity IndexedEntity
299
+ team Team
300
+ match *Match
301
+ teamResolved *ResolvedDocument
302
+ warnings []string
303
+ }
304
+
305
+ func (s *TeamService) resolveTeamLookup(ctx context.Context, teamQuery string, opts TeamLookupOptions) (*teamLookup, *NormalizedResult) {
306
+ teamQuery = strings.TrimSpace(teamQuery)
307
+ if teamQuery == "" {
308
+ result := NormalizedResult{Kind: EntityTeam, Status: ResultStatusEmpty, Message: "team query is required"}
309
+ return nil, &result
310
+ }
311
+
312
+ warnings := make([]string, 0)
313
+ var match *Match
314
+ if strings.TrimSpace(opts.MatchQuery) != "" {
315
+ resolvedMatch, matchWarnings, passthrough := s.resolveMatchContext(ctx, opts)
316
+ if passthrough != nil {
317
+ return nil, passthrough
318
+ }
319
+ match = resolvedMatch
320
+ warnings = append(warnings, matchWarnings...)
321
+ }
322
+
323
+ searchResult, err := s.resolver.Search(ctx, EntityTeam, teamQuery, ResolveOptions{
324
+ Limit: 5,
325
+ LeagueID: strings.TrimSpace(opts.LeagueID),
326
+ MatchID: teamLookupMatchID(match),
327
+ })
328
+ if err != nil {
329
+ result := NewTransportErrorResult(EntityTeam, teamQuery, err)
330
+ return nil, &result
331
+ }
332
+ if len(searchResult.Entities) == 0 {
333
+ result := NormalizedResult{Kind: EntityTeam, Status: ResultStatusEmpty, Message: fmt.Sprintf("no teams found for %q", teamQuery)}
334
+ return nil, &result
335
+ }
336
+
337
+ warnings = append(warnings, searchResult.Warnings...)
338
+ entity := searchResult.Entities[0]
339
+ team, teamResolved, teamWarning := s.fetchGlobalTeam(ctx, entity)
340
+ if strings.TrimSpace(teamWarning) != "" {
341
+ warnings = append(warnings, teamWarning)
342
+ }
343
+
344
+ if match != nil {
345
+ if competitor := matchTeamByID(*match, entity.ID); competitor != nil {
346
+ team = mergeTeamViews(team, *competitor, *match)
347
+ } else {
348
+ warnings = append(warnings, fmt.Sprintf("team %s not found in match %s competitors", entity.ID, match.ID))
349
+ }
350
+ } else {
351
+ if team.Extensions == nil {
352
+ team.Extensions = map[string]any{}
353
+ }
354
+ team.Extensions["scope"] = string(TeamScopeGlobal)
355
+ }
356
+
357
+ return &teamLookup{
358
+ entity: entity,
359
+ team: team,
360
+ match: match,
361
+ teamResolved: teamResolved,
362
+ warnings: compactWarnings(warnings),
363
+ }, nil
364
+ }
365
+
366
+ func (s *TeamService) resolveMatchContext(ctx context.Context, opts TeamLookupOptions) (*Match, []string, *NormalizedResult) {
367
+ query := strings.TrimSpace(opts.MatchQuery)
368
+ if query == "" {
369
+ return nil, nil, nil
370
+ }
371
+
372
+ searchResult, err := s.resolver.Search(ctx, EntityMatch, query, ResolveOptions{Limit: 5, LeagueID: strings.TrimSpace(opts.LeagueID)})
373
+ if err != nil {
374
+ result := NewTransportErrorResult(EntityMatch, query, err)
375
+ return nil, nil, &result
376
+ }
377
+ if len(searchResult.Entities) == 0 {
378
+ result := NormalizedResult{Kind: EntityMatch, Status: ResultStatusEmpty, Message: fmt.Sprintf("no matches found for %q", query)}
379
+ return nil, nil, &result
380
+ }
381
+
382
+ entity := searchResult.Entities[0]
383
+ ref := buildMatchRef(entity)
384
+ if strings.TrimSpace(ref) == "" {
385
+ result := NormalizedResult{Kind: EntityMatch, Status: ResultStatusEmpty, Message: fmt.Sprintf("unable to resolve match ref for %q", query)}
386
+ return nil, nil, &result
387
+ }
388
+
389
+ resolved, err := s.client.ResolveRefChain(ctx, ref)
390
+ if err != nil {
391
+ result := NewTransportErrorResult(EntityMatch, ref, err)
392
+ return nil, nil, &result
393
+ }
394
+
395
+ match, err := NormalizeMatch(resolved.Body)
396
+ if err != nil {
397
+ result := NormalizedResult{Kind: EntityMatch, Status: ResultStatusError, Message: fmt.Sprintf("normalize competition match %q: %v", resolved.CanonicalRef, err)}
398
+ return nil, nil, &result
399
+ }
400
+
401
+ warnings := append([]string{}, searchResult.Warnings...)
402
+ return match, compactWarnings(warnings), nil
403
+ }
404
+
405
+ func (s *TeamService) fetchGlobalTeam(ctx context.Context, entity IndexedEntity) (Team, *ResolvedDocument, string) {
406
+ fallback := Team{
407
+ Ref: entity.Ref,
408
+ ID: entity.ID,
409
+ Name: entity.Name,
410
+ ShortName: entity.ShortName,
411
+ }
412
+
413
+ teamRef := strings.TrimSpace(entity.Ref)
414
+ if teamRef == "" || strings.Contains(teamRef, "/competitors/") {
415
+ teamRef = "/teams/" + strings.TrimSpace(entity.ID)
416
+ }
417
+
418
+ resolved, err := s.client.ResolveRefChain(ctx, teamRef)
419
+ if err != nil {
420
+ return fallback, nil, fmt.Sprintf("team %s: %v", entity.ID, err)
421
+ }
422
+
423
+ team, err := NormalizeTeam(resolved.Body)
424
+ if err != nil {
425
+ return fallback, resolved, fmt.Sprintf("team %s: %v", entity.ID, err)
426
+ }
427
+ if team.ID == "" {
428
+ team.ID = entity.ID
429
+ }
430
+ if team.Name == "" {
431
+ team.Name = entity.Name
432
+ }
433
+ if team.ShortName == "" {
434
+ team.ShortName = entity.ShortName
435
+ }
436
+ if team.Extensions == nil {
437
+ team.Extensions = map[string]any{}
438
+ }
439
+ team.Extensions["scope"] = string(TeamScopeGlobal)
440
+
441
+ return *team, resolved, ""
442
+ }
443
+
444
+ func (s *TeamService) enrichRosterEntries(entries []TeamRosterEntry) {
445
+ if s.resolver == nil || s.resolver.index == nil {
446
+ return
447
+ }
448
+
449
+ for i := range entries {
450
+ if strings.TrimSpace(entries[i].PlayerID) == "" {
451
+ continue
452
+ }
453
+ player, ok := s.resolver.index.FindByID(EntityPlayer, entries[i].PlayerID)
454
+ if !ok {
455
+ continue
456
+ }
457
+ if strings.TrimSpace(entries[i].DisplayName) == "" {
458
+ entries[i].DisplayName = nonEmpty(player.Name, player.ShortName)
459
+ }
460
+ if strings.TrimSpace(entries[i].PlayerRef) == "" {
461
+ entries[i].PlayerRef = strings.TrimSpace(player.Ref)
462
+ }
463
+ }
464
+ }
465
+
466
+ func (s *TeamService) enrichTeamLeaders(ctx context.Context, leaders *TeamLeaders) {
467
+ if leaders == nil || s.resolver == nil || s.resolver.index == nil {
468
+ return
469
+ }
470
+
471
+ for categoryIndex := range leaders.Categories {
472
+ for leaderIndex := range leaders.Categories[categoryIndex].Leaders {
473
+ entry := &leaders.Categories[categoryIndex].Leaders[leaderIndex]
474
+ if strings.TrimSpace(entry.AthleteID) == "" {
475
+ entry.AthleteID = strings.TrimSpace(refIDs(entry.AthleteRef)["athleteId"])
476
+ }
477
+ if strings.TrimSpace(entry.AthleteID) != "" {
478
+ if indexed, ok := s.resolver.index.FindByID(EntityPlayer, entry.AthleteID); !ok || strings.TrimSpace(nonEmpty(indexed.Name, indexed.ShortName)) == "" {
479
+ _ = s.resolver.seedPlayerByID(ctx, entry.AthleteID, "", strings.TrimSpace(leaders.MatchID))
480
+ }
481
+ }
482
+ if strings.TrimSpace(entry.AthleteName) == "" || strings.EqualFold(strings.TrimSpace(entry.AthleteName), "Unknown player") {
483
+ if strings.TrimSpace(entry.AthleteID) == "" {
484
+ entry.AthleteName = "Unknown player"
485
+ continue
486
+ }
487
+ if player, ok := s.resolver.index.FindByID(EntityPlayer, entry.AthleteID); ok {
488
+ entry.AthleteName = nonEmpty(player.Name, player.ShortName, "Unknown player")
489
+ } else {
490
+ entry.AthleteName = "Unknown player"
491
+ }
492
+ }
493
+ if strings.TrimSpace(entry.AthleteName) == "" {
494
+ entry.AthleteName = "Unknown player"
495
+ }
496
+ }
497
+ }
498
+ }
499
+
500
+ func matchScopeRequiredResult(kind EntityKind) NormalizedResult {
501
+ return NormalizedResult{
502
+ Kind: kind,
503
+ Status: ResultStatusEmpty,
504
+ Message: "match scope is required (use --match <match>)",
505
+ }
506
+ }
507
+
508
+ func competitorSubresourceRef(match Match, teamID, suffix string) string {
509
+ teamID = strings.TrimSpace(teamID)
510
+ suffix = strings.Trim(strings.TrimSpace(suffix), "/")
511
+ if teamID == "" {
512
+ return ""
513
+ }
514
+
515
+ base := matchSubresourceRef(match, "", "")
516
+ if base == "" {
517
+ return ""
518
+ }
519
+
520
+ ref := strings.TrimRight(base, "/") + "/competitors/" + teamID
521
+ if suffix != "" {
522
+ ref += "/" + suffix
523
+ }
524
+ return ref
525
+ }
526
+
527
+ func matchTeamByID(match Match, teamID string) *Team {
528
+ teamID = strings.TrimSpace(teamID)
529
+ if teamID == "" {
530
+ return nil
531
+ }
532
+
533
+ for i := range match.Teams {
534
+ candidate := &match.Teams[i]
535
+ ids := []string{
536
+ strings.TrimSpace(candidate.ID),
537
+ strings.TrimSpace(refIDs(candidate.Ref)["teamId"]),
538
+ strings.TrimSpace(refIDs(candidate.Ref)["competitorId"]),
539
+ }
540
+ for _, id := range ids {
541
+ if id != "" && id == teamID {
542
+ return candidate
543
+ }
544
+ }
545
+ }
546
+
547
+ return nil
548
+ }
549
+
550
+ func mergeTeamViews(global Team, competitor Team, match Match) Team {
551
+ merged := global
552
+ if merged.Ref == "" {
553
+ merged.Ref = competitor.Ref
554
+ }
555
+ if merged.ID == "" {
556
+ merged.ID = competitor.ID
557
+ }
558
+ if merged.Name == "" {
559
+ merged.Name = competitor.Name
560
+ }
561
+ if merged.ShortName == "" {
562
+ merged.ShortName = competitor.ShortName
563
+ }
564
+ if merged.Abbreviation == "" {
565
+ merged.Abbreviation = competitor.Abbreviation
566
+ }
567
+ if competitor.ScoreSummary != "" {
568
+ merged.ScoreSummary = competitor.ScoreSummary
569
+ }
570
+ if competitor.Type != "" {
571
+ merged.Type = competitor.Type
572
+ }
573
+ if competitor.HomeAway != "" {
574
+ merged.HomeAway = competitor.HomeAway
575
+ }
576
+ if competitor.Order != 0 {
577
+ merged.Order = competitor.Order
578
+ }
579
+ merged.Winner = competitor.Winner
580
+ merged.ScoreRef = nonEmpty(competitor.ScoreRef, merged.ScoreRef)
581
+ merged.RosterRef = nonEmpty(competitor.RosterRef, merged.RosterRef)
582
+ merged.LeadersRef = nonEmpty(competitor.LeadersRef, merged.LeadersRef)
583
+ merged.StatisticsRef = nonEmpty(competitor.StatisticsRef, merged.StatisticsRef)
584
+ merged.RecordRef = nonEmpty(competitor.RecordRef, merged.RecordRef)
585
+ merged.LinescoresRef = nonEmpty(competitor.LinescoresRef, merged.LinescoresRef)
586
+
587
+ if merged.Extensions == nil {
588
+ merged.Extensions = map[string]any{}
589
+ }
590
+ merged.Extensions["scope"] = string(TeamScopeMatch)
591
+ merged.Extensions["matchId"] = match.ID
592
+ merged.Extensions["competitionId"] = match.CompetitionID
593
+ merged.Extensions["eventId"] = match.EventID
594
+ merged.Extensions["leagueId"] = match.LeagueID
595
+ return merged
596
+ }
597
+
598
+ func teamLookupMatchID(match *Match) string {
599
+ if match == nil {
600
+ return ""
601
+ }
602
+ return strings.TrimSpace(match.ID)
603
+ }