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,280 @@
1
+ package cricinfo
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "errors"
7
+ "net/http"
8
+ "net/http/httptest"
9
+ "sync/atomic"
10
+ "testing"
11
+ "time"
12
+ )
13
+
14
+ func TestFetchRetriesServerErrorsAndPreservesCanonicalRef(t *testing.T) {
15
+ t.Parallel()
16
+
17
+ var attempts atomic.Int32
18
+ slept := make([]time.Duration, 0, 2)
19
+
20
+ var server *httptest.Server
21
+ server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
22
+ if r.UserAgent() == "" {
23
+ t.Fatalf("expected user agent to be set")
24
+ }
25
+
26
+ current := attempts.Add(1)
27
+ if current <= 2 {
28
+ w.WriteHeader(http.StatusServiceUnavailable)
29
+ _, _ = w.Write([]byte(`{"error":"transient"}`))
30
+ return
31
+ }
32
+
33
+ w.Header().Set("Content-Type", "application/json")
34
+ _, _ = w.Write([]byte(`{"$ref":"` + server.URL + `/canonical/events","count":0,"items":[],"pageCount":0,"pageIndex":1,"pageSize":25}`))
35
+ }))
36
+ defer server.Close()
37
+
38
+ client, err := NewClient(Config{
39
+ BaseURL: server.URL,
40
+ MaxRetries: 2,
41
+ RetryBaseDelay: 10 * time.Millisecond,
42
+ RetryMaxDelay: 25 * time.Millisecond,
43
+ RetryJitter: 0.25,
44
+ RandomFloat64: func() float64 { return 0.5 },
45
+ Sleep: func(_ context.Context, d time.Duration) error {
46
+ slept = append(slept, d)
47
+ return nil
48
+ },
49
+ })
50
+ if err != nil {
51
+ t.Fatalf("NewClient error: %v", err)
52
+ }
53
+
54
+ doc, err := client.Fetch(context.Background(), "/events")
55
+ if err != nil {
56
+ t.Fatalf("Fetch error: %v", err)
57
+ }
58
+
59
+ if got := attempts.Load(); got != 3 {
60
+ t.Fatalf("expected 3 attempts, got %d", got)
61
+ }
62
+ if len(slept) != 2 {
63
+ t.Fatalf("expected 2 backoff sleeps, got %d", len(slept))
64
+ }
65
+ if slept[0] != 10*time.Millisecond || slept[1] != 20*time.Millisecond {
66
+ t.Fatalf("unexpected backoff durations: %v", slept)
67
+ }
68
+ if doc.RequestedRef != server.URL+"/events" {
69
+ t.Fatalf("unexpected requested ref: %q", doc.RequestedRef)
70
+ }
71
+ if doc.CanonicalRef != server.URL+"/canonical/events" {
72
+ t.Fatalf("unexpected canonical ref: %q", doc.CanonicalRef)
73
+ }
74
+ }
75
+
76
+ func TestFetchTimeout(t *testing.T) {
77
+ t.Parallel()
78
+
79
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
80
+ time.Sleep(200 * time.Millisecond)
81
+ w.WriteHeader(http.StatusOK)
82
+ _, _ = w.Write([]byte(`{"ok":true}`))
83
+ }))
84
+ defer server.Close()
85
+
86
+ client, err := NewClient(Config{
87
+ BaseURL: server.URL,
88
+ Timeout: 25 * time.Millisecond,
89
+ MaxRetries: 0,
90
+ })
91
+ if err != nil {
92
+ t.Fatalf("NewClient error: %v", err)
93
+ }
94
+
95
+ start := time.Now()
96
+ _, err = client.Fetch(context.Background(), "/slow")
97
+ if err == nil {
98
+ t.Fatal("expected timeout error")
99
+ }
100
+ if !errors.Is(err, context.DeadlineExceeded) {
101
+ t.Fatalf("expected deadline exceeded, got %v", err)
102
+ }
103
+ if elapsed := time.Since(start); elapsed > 150*time.Millisecond {
104
+ t.Fatalf("timeout took too long: %v", elapsed)
105
+ }
106
+ }
107
+
108
+ func TestFetchDoesNotRetryAfterContextCancel(t *testing.T) {
109
+ t.Parallel()
110
+
111
+ var hits atomic.Int32
112
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
113
+ hits.Add(1)
114
+ w.WriteHeader(http.StatusOK)
115
+ _, _ = w.Write([]byte(`{"ok":true}`))
116
+ }))
117
+ defer server.Close()
118
+
119
+ client, err := NewClient(Config{BaseURL: server.URL, MaxRetries: 3})
120
+ if err != nil {
121
+ t.Fatalf("NewClient error: %v", err)
122
+ }
123
+
124
+ ctx, cancel := context.WithCancel(context.Background())
125
+ cancel()
126
+
127
+ _, err = client.Fetch(ctx, "/events")
128
+ if err == nil {
129
+ t.Fatal("expected canceled context error")
130
+ }
131
+ if !errors.Is(err, context.Canceled) {
132
+ t.Fatalf("expected context canceled error, got %v", err)
133
+ }
134
+ if got := hits.Load(); got > 1 {
135
+ t.Fatalf("expected at most one hit after cancellation, got %d", got)
136
+ }
137
+ }
138
+
139
+ func TestRetryDelayBackoffJitterAndCap(t *testing.T) {
140
+ t.Parallel()
141
+
142
+ client := &Client{
143
+ retryBaseDelay: 100 * time.Millisecond,
144
+ retryMaxDelay: 250 * time.Millisecond,
145
+ retryJitter: 0.5,
146
+ randomFloat64: func() float64 { return 1.0 },
147
+ }
148
+
149
+ if got := client.retryDelay(0); got != 150*time.Millisecond {
150
+ t.Fatalf("attempt 0 delay mismatch: %v", got)
151
+ }
152
+ if got := client.retryDelay(1); got != 250*time.Millisecond {
153
+ t.Fatalf("attempt 1 delay should cap at max delay, got %v", got)
154
+ }
155
+ if got := client.retryDelay(8); got != 250*time.Millisecond {
156
+ t.Fatalf("late-attempt delay should stay capped, got %v", got)
157
+ }
158
+ }
159
+
160
+ func TestResolveRefTreatsLeadingSlashAsAPIRelative(t *testing.T) {
161
+ t.Parallel()
162
+
163
+ client, err := NewClient(Config{BaseURL: DefaultBaseURL})
164
+ if err != nil {
165
+ t.Fatalf("NewClient error: %v", err)
166
+ }
167
+
168
+ got, err := client.resolveRef("/events")
169
+ if err != nil {
170
+ t.Fatalf("resolveRef error: %v", err)
171
+ }
172
+ if got != DefaultBaseURL+"/events" {
173
+ t.Fatalf("unexpected API-relative URL: %q", got)
174
+ }
175
+
176
+ absoluteGot, absoluteErr := client.resolveRef("/v2/sports/cricket/events")
177
+ if absoluteErr != nil {
178
+ t.Fatalf("resolveRef absolute path error: %v", absoluteErr)
179
+ }
180
+ if absoluteGot != DefaultBaseURL+"/events" {
181
+ t.Fatalf("unexpected absolute API URL: %q", absoluteGot)
182
+ }
183
+ }
184
+
185
+ func TestResolveRefChainFollowsPointers(t *testing.T) {
186
+ t.Parallel()
187
+
188
+ var server *httptest.Server
189
+ server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
190
+ switch r.URL.Path {
191
+ case "/start":
192
+ _, _ = w.Write([]byte(`{"$ref":"` + server.URL + `/middle"}`))
193
+ case "/middle":
194
+ _, _ = w.Write([]byte(`{"$ref":"/final"}`))
195
+ case "/final":
196
+ _, _ = w.Write([]byte(`{"$ref":"http://core.espnuk.org/v2/sports/cricket/leagues/1174248/events/1/competitions/1/status","summary":"ok"}`))
197
+ default:
198
+ w.WriteHeader(http.StatusNotFound)
199
+ }
200
+ }))
201
+ defer server.Close()
202
+
203
+ client, err := NewClient(Config{BaseURL: server.URL, MaxRetries: 0})
204
+ if err != nil {
205
+ t.Fatalf("NewClient error: %v", err)
206
+ }
207
+
208
+ resolved, err := client.ResolveRefChain(context.Background(), "/start")
209
+ if err != nil {
210
+ t.Fatalf("ResolveRefChain error: %v", err)
211
+ }
212
+
213
+ expectedTraversed := []string{server.URL + "/start", server.URL + "/middle", server.URL + "/final"}
214
+ if len(resolved.TraversedRef) != len(expectedTraversed) {
215
+ t.Fatalf("expected %d traversed refs, got %d", len(expectedTraversed), len(resolved.TraversedRef))
216
+ }
217
+ for i := range expectedTraversed {
218
+ if resolved.TraversedRef[i] != expectedTraversed[i] {
219
+ t.Fatalf("unexpected traversed ref at %d: got %q want %q", i, resolved.TraversedRef[i], expectedTraversed[i])
220
+ }
221
+ }
222
+
223
+ if resolved.RequestedRef != server.URL+"/start" {
224
+ t.Fatalf("unexpected requested ref: %q", resolved.RequestedRef)
225
+ }
226
+ if resolved.CanonicalRef != "http://core.espnuk.org/v2/sports/cricket/leagues/1174248/events/1/competitions/1/status" {
227
+ t.Fatalf("unexpected canonical ref: %q", resolved.CanonicalRef)
228
+ }
229
+
230
+ var payload map[string]any
231
+ if err := json.Unmarshal(resolved.Body, &payload); err != nil {
232
+ t.Fatalf("unmarshal resolved body: %v", err)
233
+ }
234
+ if payload["summary"] != "ok" {
235
+ t.Fatalf("unexpected summary: %v", payload["summary"])
236
+ }
237
+ }
238
+
239
+ func TestResolveRefChainDetectsLoop(t *testing.T) {
240
+ t.Parallel()
241
+
242
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
243
+ switch r.URL.Path {
244
+ case "/a":
245
+ _, _ = w.Write([]byte(`{"$ref":"/b"}`))
246
+ case "/b":
247
+ _, _ = w.Write([]byte(`{"$ref":"/a"}`))
248
+ default:
249
+ w.WriteHeader(http.StatusNotFound)
250
+ }
251
+ }))
252
+ defer server.Close()
253
+
254
+ client, err := NewClient(Config{BaseURL: server.URL, MaxRetries: 0})
255
+ if err != nil {
256
+ t.Fatalf("NewClient error: %v", err)
257
+ }
258
+
259
+ _, err = client.ResolveRefChain(context.Background(), "/a")
260
+ if err == nil {
261
+ t.Fatal("expected loop error")
262
+ }
263
+ if !errors.Is(err, ErrPointerLoop) {
264
+ t.Fatalf("expected pointer loop error, got %v", err)
265
+ }
266
+ }
267
+
268
+ func TestFollowRefMissing(t *testing.T) {
269
+ t.Parallel()
270
+
271
+ client, err := NewClient(Config{})
272
+ if err != nil {
273
+ t.Fatalf("NewClient error: %v", err)
274
+ }
275
+
276
+ _, err = client.FollowRef(context.Background(), nil)
277
+ if !errors.Is(err, ErrMissingRef) {
278
+ t.Fatalf("expected missing ref error, got %v", err)
279
+ }
280
+ }
@@ -0,0 +1,145 @@
1
+ package main
2
+
3
+ import (
4
+ "bytes"
5
+ "context"
6
+ "encoding/json"
7
+ "errors"
8
+ "flag"
9
+ "fmt"
10
+ "os"
11
+ "path/filepath"
12
+ "strings"
13
+ "time"
14
+
15
+ "github.com/amxv/cricinfo-cli/internal/cricinfo"
16
+ )
17
+
18
+ const defaultOutputRoot = "internal/cricinfo/testdata/fixtures"
19
+
20
+ func main() {
21
+ var (
22
+ familiesRaw string
23
+ outputRoot string
24
+ timeout time.Duration
25
+ maxRetries int
26
+ write bool
27
+ )
28
+
29
+ flag.StringVar(&familiesRaw, "families", "", "comma-separated fixture families to refresh")
30
+ flag.StringVar(&outputRoot, "output", defaultOutputRoot, "fixture output root directory")
31
+ flag.DurationVar(&timeout, "timeout", 12*time.Second, "per-request timeout")
32
+ flag.IntVar(&maxRetries, "max-retries", 3, "max retries per request")
33
+ flag.BoolVar(&write, "write", false, "write fixtures to disk (required for refresh)")
34
+ flag.Parse()
35
+
36
+ if !write {
37
+ fmt.Println("dry-run: pass --write to refresh fixture files")
38
+ return
39
+ }
40
+
41
+ selected, err := cricinfo.ParseFixtureFamilies(familiesRaw)
42
+ if err != nil {
43
+ fatalf("parse families: %v", err)
44
+ }
45
+
46
+ matrix := cricinfo.FixtureMatrix()
47
+ matrix = cricinfo.FilterFixtureMatrixByFamily(matrix, selected)
48
+ if len(matrix) == 0 {
49
+ fatalf("no fixture specs selected")
50
+ }
51
+
52
+ client, err := cricinfo.NewClient(cricinfo.Config{
53
+ Timeout: timeout,
54
+ MaxRetries: maxRetries,
55
+ })
56
+ if err != nil {
57
+ fatalf("new client: %v", err)
58
+ }
59
+
60
+ if err := os.MkdirAll(outputRoot, 0o755); err != nil {
61
+ fatalf("create output root %q: %v", outputRoot, err)
62
+ }
63
+
64
+ refreshed := 0
65
+ keptExisting := 0
66
+ for _, spec := range matrix {
67
+ targetPath := filepath.Join(outputRoot, filepath.FromSlash(spec.FixturePath))
68
+ if err := ensurePathUnderRoot(outputRoot, targetPath); err != nil {
69
+ fatalf("invalid fixture path for %s/%s: %v", spec.Family, spec.Name, err)
70
+ }
71
+
72
+ ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
73
+ resolved, fetchErr := client.ResolveRefChain(ctx, spec.Ref)
74
+ cancel()
75
+
76
+ if fetchErr != nil {
77
+ var statusErr *cricinfo.HTTPStatusError
78
+ if errors.As(fetchErr, &statusErr) && statusErr.StatusCode == 503 {
79
+ if _, statErr := os.Stat(targetPath); statErr == nil {
80
+ fmt.Printf("! kept existing fixture after persistent 503: %s (%s)\n", spec.Name, spec.Ref)
81
+ keptExisting++
82
+ continue
83
+ }
84
+ fatalf("persistent 503 and no existing fixture for %s (%s)", spec.Name, spec.Ref)
85
+ }
86
+ fatalf("refresh %s (%s): %v", spec.Name, spec.Ref, fetchErr)
87
+ }
88
+
89
+ formatted := formatJSONOrOriginal(resolved.Body)
90
+ if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
91
+ fatalf("create fixture directory for %q: %v", targetPath, err)
92
+ }
93
+ if err := os.WriteFile(targetPath, formatted, 0o644); err != nil {
94
+ fatalf("write fixture %q: %v", targetPath, err)
95
+ }
96
+
97
+ fmt.Printf("+ refreshed %s -> %s\n", spec.Ref, targetPath)
98
+ refreshed++
99
+ }
100
+
101
+ if err := writeMatrixTSV(outputRoot, matrix); err != nil {
102
+ fatalf("write endpoint matrix: %v", err)
103
+ }
104
+
105
+ fmt.Printf("refresh complete: refreshed=%d kept-existing=%d total=%d\n", refreshed, keptExisting, len(matrix))
106
+ }
107
+
108
+ func formatJSONOrOriginal(raw []byte) []byte {
109
+ var out bytes.Buffer
110
+ if err := json.Indent(&out, raw, "", " "); err != nil {
111
+ return raw
112
+ }
113
+ out.WriteByte('\n')
114
+ return out.Bytes()
115
+ }
116
+
117
+ func writeMatrixTSV(outputRoot string, matrix []cricinfo.FixtureSpec) error {
118
+ var b strings.Builder
119
+ b.WriteString("family\tname\tref\tfixture_path\tlive_probe\n")
120
+ for _, spec := range matrix {
121
+ line := fmt.Sprintf("%s\t%s\t%s\t%s\t%t\n", spec.Family, spec.Name, spec.Ref, spec.FixturePath, spec.LiveProbe)
122
+ b.WriteString(line)
123
+ }
124
+
125
+ path := filepath.Join(outputRoot, "endpoint-matrix.tsv")
126
+ return os.WriteFile(path, []byte(b.String()), 0o644)
127
+ }
128
+
129
+ func ensurePathUnderRoot(root, path string) error {
130
+ cleanRoot := filepath.Clean(root)
131
+ cleanPath := filepath.Clean(path)
132
+ if cleanPath == cleanRoot {
133
+ return nil
134
+ }
135
+ prefix := cleanRoot + string(os.PathSeparator)
136
+ if !strings.HasPrefix(cleanPath, prefix) {
137
+ return fmt.Errorf("path %q escapes root %q", path, root)
138
+ }
139
+ return nil
140
+ }
141
+
142
+ func fatalf(format string, args ...any) {
143
+ fmt.Fprintf(os.Stderr, "error: "+format+"\n", args...)
144
+ os.Exit(1)
145
+ }