diff --git a/internal/api/cursor_spend_limit_test.go b/internal/api/cursor_spend_limit_test.go new file mode 100644 index 00000000..0d325f1e --- /dev/null +++ b/internal/api/cursor_spend_limit_test.go @@ -0,0 +1,42 @@ +package api + +import ( + "encoding/json" + "testing" +) + +func TestCursorSpendLimitUsage_AcceptsStringsAndNumbers(t *testing.T) { + cases := []struct { + name string + in string + want CursorSpendLimitUsage + }{ + {"numbers", `{"totalSpend":150,"pooledLimit":2000,"pooledUsed":150,"pooledRemaining":1850,"limitType":"user"}`, + CursorSpendLimitUsage{TotalSpend: 150, PooledLimit: 2000, PooledUsed: 150, PooledRemaining: 1850, LimitType: "user"}}, + {"numeric strings", `{"pooledLimit":"2000","pooledUsed":"0","individualLimit":"500"}`, + CursorSpendLimitUsage{PooledLimit: 2000, IndividualLimit: 500}}, + {"empty and null", `{"pooledLimit":"","pooledUsed":null,"limitType":null}`, + CursorSpendLimitUsage{}}, + {"float", `{"totalSpend":12.0}`, CursorSpendLimitUsage{TotalSpend: 12}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var got CursorSpendLimitUsage + if err := json.Unmarshal([]byte(tc.in), &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got != tc.want { + t.Fatalf("got %+v, want %+v", got, tc.want) + } + }) + } +} + +func TestCursorSpendLimitUsage_NestedInUsageResponse(t *testing.T) { + var wrapper struct { + SpendLimitUsage CursorSpendLimitUsage `json:"spendLimitUsage"` + } + if err := json.Unmarshal([]byte(`{"spendLimitUsage":{"pooledLimit":"0","totalSpend":0}}`), &wrapper); err != nil { + t.Fatalf("unmarshal nested: %v", err) + } +} diff --git a/internal/api/cursor_types.go b/internal/api/cursor_types.go index 0ce6e599..e3733e26 100644 --- a/internal/api/cursor_types.go +++ b/internal/api/cursor_types.go @@ -508,3 +508,63 @@ func buildEnterpriseQuotas(usage *CursorUsageResponse, requestUsage *CursorReque return quotas } + +// UnmarshalJSON accepts the integer fields of spendLimitUsage either as JSON +// numbers or as numeric strings. Cursor returns some of them as strings +// (e.g. "pooledLimit":"0" on free accounts), which broke decoding of the whole +// usage response. Empty or non-numeric strings decode as 0. +func (u *CursorSpendLimitUsage) UnmarshalJSON(data []byte) error { + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + ints := map[string]*int{ + "totalSpend": &u.TotalSpend, + "pooledLimit": &u.PooledLimit, + "pooledUsed": &u.PooledUsed, + "pooledRemaining": &u.PooledRemaining, + "individualLimit": &u.IndividualLimit, + "individualUsed": &u.IndividualUsed, + "individualRemaining": &u.IndividualRemaining, + } + for key, dst := range ints { + v, ok := raw[key] + if !ok { + continue + } + n, err := flexibleInt(v) + if err != nil { + return fmt.Errorf("cursor: spendLimitUsage.%s: %w", key, err) + } + *dst = n + } + if v, ok := raw["limitType"]; ok && string(v) != "null" { + if err := json.Unmarshal(v, &u.LimitType); err != nil { + return fmt.Errorf("cursor: spendLimitUsage.limitType: %w", err) + } + } + return nil +} + +// flexibleInt decodes a JSON number, numeric string, empty string or null as int. +func flexibleInt(v json.RawMessage) (int, error) { + s := strings.TrimSpace(string(v)) + if s == "" || s == "null" { + return 0, nil + } + if s[0] == 0x22 { + var str string + if err := json.Unmarshal(v, &str); err != nil { + return 0, err + } + s = strings.TrimSpace(str) + if s == "" { + return 0, nil + } + } + f, err := strconv.ParseFloat(s, 64) + if err != nil { + return 0, nil + } + return int(f), nil +}