1use chrono::{DateTime, Utc};
48use serde::Deserialize;
49
50use crate::error::{AppError, Result};
51use crate::usage::{CursorCreditGrant, CursorSnapshot};
52
53#[derive(Debug, Clone, Deserialize)]
54pub struct UsageSummary {
55 #[serde(rename = "membershipType", default)]
56 pub membership_type: String,
57 #[serde(rename = "isUnlimited", default)]
58 pub is_unlimited: bool,
59 #[serde(rename = "billingCycleEnd")]
63 pub billing_cycle_end: String,
64 #[serde(rename = "billingCycleStart", default)]
67 pub billing_cycle_start: Option<String>,
68 #[serde(rename = "individualUsage")]
69 pub individual_usage: Option<IndividualUsage>,
70 #[serde(rename = "teamUsage", default)]
75 pub team_usage: Option<TeamUsage>,
76 #[serde(rename = "autoModelSelectedDisplayMessage", default)]
81 pub auto_model_selected_display_message: Option<String>,
82 #[serde(rename = "namedModelSelectedDisplayMessage", default)]
85 pub named_model_selected_display_message: Option<String>,
86}
87
88#[derive(Debug, Clone, Deserialize)]
89pub struct IndividualUsage {
90 pub plan: Option<PlanUsage>,
91 #[serde(rename = "onDemand", default)]
96 pub on_demand: Option<OnDemand>,
97}
98
99#[derive(Debug, Clone, Deserialize)]
100pub struct TeamUsage {
101 #[serde(rename = "onDemand", default)]
102 pub on_demand: Option<OnDemand>,
103}
104
105#[derive(Debug, Clone, Deserialize)]
106pub struct PlanUsage {
107 #[serde(rename = "autoPercentUsed")]
109 pub auto_percent_used: f64,
110 #[serde(rename = "apiPercentUsed")]
112 pub api_percent_used: f64,
113 #[serde(rename = "totalPercentUsed")]
117 pub total_percent_used: f64,
118}
119
120#[derive(Debug, Clone, Deserialize)]
121pub struct OnDemand {
122 #[serde(default)]
123 pub enabled: bool,
124 #[serde(default)]
125 pub used: Option<i64>,
126 #[serde(default)]
127 pub limit: Option<i64>,
128}
129
130fn pct(field: &str, v: f64) -> Result<i32> {
135 if !v.is_finite() {
136 return Err(AppError::Schema(format!(
137 "cursor: `{field}` is not a finite number"
138 )));
139 }
140 let rounded = v.round().max(0.0);
145 if rounded > f64::from(i32::MAX) {
146 return Err(AppError::Schema(format!(
147 "cursor: `{field}` is too large to represent"
148 )));
149 }
150 Ok(rounded as i32)
151}
152
153pub fn to_snapshot(resp: UsageSummary) -> Result<CursorSnapshot> {
154 let reset_at = DateTime::parse_from_rfc3339(&resp.billing_cycle_end)
155 .map_err(|e| {
156 AppError::Schema(format!(
157 "cursor: `billingCycleEnd` is not RFC3339 ({:?}): {e}",
158 resp.billing_cycle_end
159 ))
160 })?
161 .with_timezone(&Utc);
162 let cycle_start = resp
164 .billing_cycle_start
165 .as_deref()
166 .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
167 .map(|dt| dt.with_timezone(&Utc));
168
169 let plan = title_case(&resp.membership_type);
170
171 if resp.is_unlimited {
175 return Ok(CursorSnapshot {
176 plan,
177 auto_pct: 0,
178 api_pct: 0,
179 total_pct: 0,
180 unlimited: true,
181 on_demand_enabled: false,
182 on_demand_used_cents: None,
183 on_demand_limit_cents: None,
184 reset_at: Some(reset_at),
185 cycle_start,
186 credits: Vec::new(),
187 });
188 }
189
190 let on_demand = resp
194 .individual_usage
195 .as_ref()
196 .and_then(|u| u.on_demand.as_ref())
197 .or_else(|| resp.team_usage.as_ref().and_then(|t| t.on_demand.as_ref()));
198 let on_demand_enabled = on_demand.is_some_and(|o| o.enabled);
199 let on_demand_used_cents = on_demand.and_then(|o| o.used).filter(|v| *v >= 0);
200 let on_demand_limit_cents = on_demand.and_then(|o| o.limit).filter(|v| *v >= 0);
201
202 if let Some(plan_usage) = resp.individual_usage.as_ref().and_then(|u| u.plan.as_ref()) {
203 return Ok(CursorSnapshot {
204 plan,
205 auto_pct: pct("autoPercentUsed", plan_usage.auto_percent_used)?,
206 api_pct: pct("apiPercentUsed", plan_usage.api_percent_used)?,
207 total_pct: pct("totalPercentUsed", plan_usage.total_percent_used)?,
208 unlimited: false,
209 on_demand_enabled,
210 on_demand_used_cents,
211 on_demand_limit_cents,
212 reset_at: Some(reset_at),
213 cycle_start,
214 credits: Vec::new(),
215 });
216 }
217
218 let team_pcts = resp
222 .auto_model_selected_display_message
223 .as_deref()
224 .and_then(parse_percent_from_message)
225 .zip(
226 resp.named_model_selected_display_message
227 .as_deref()
228 .and_then(parse_percent_from_message),
229 );
230 if let Some((auto_raw, api_raw)) = team_pcts {
231 let auto_pct = pct("autoModelSelectedDisplayMessage", auto_raw)?;
232 let api_pct = pct("namedModelSelectedDisplayMessage", api_raw)?;
233 return Ok(CursorSnapshot {
234 plan: format!("{plan} (team)"),
238 auto_pct,
239 api_pct,
240 total_pct: auto_pct.max(api_pct),
244 unlimited: false,
245 on_demand_enabled,
246 on_demand_used_cents,
247 on_demand_limit_cents,
248 reset_at: Some(reset_at),
249 cycle_start,
250 credits: Vec::new(),
251 });
252 }
253
254 Err(AppError::Schema(
255 "cursor: response has no `individualUsage.plan` and no parseable team-usage \
256 display message (unrecognized team-account shape)"
257 .into(),
258 ))
259}
260
261fn parse_percent_from_message(msg: &str) -> Option<f64> {
266 let pct_idx = msg.find('%')?;
267 let before = &msg[..pct_idx];
268 let start = before
269 .rfind(|c: char| !c.is_ascii_digit() && c != '.')
270 .map(|i| i + 1)
271 .unwrap_or(0);
272 before[start..].parse::<f64>().ok()
273}
274
275fn title_case(s: &str) -> String {
278 let mut chars = s.chars();
279 match chars.next() {
280 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
281 None => "Cursor".to_string(),
282 }
283}
284
285pub fn parse_credit_grants(bytes: &[u8]) -> Vec<CursorCreditGrant> {
293 let Ok(value) = serde_json::from_slice::<serde_json::Value>(bytes) else {
294 return Vec::new();
295 };
296 let Some(items) = value.get("grants").and_then(serde_json::Value::as_array) else {
297 return Vec::new();
298 };
299 let mut grants: Vec<CursorCreditGrant> = items.iter().filter_map(grant_from_value).collect();
300 grants.sort_by(|a, b| match (a.expires_at, b.expires_at) {
301 (Some(left), Some(right)) => left.cmp(&right),
302 (Some(_), None) => std::cmp::Ordering::Less,
303 (None, Some(_)) => std::cmp::Ordering::Greater,
304 (None, None) => std::cmp::Ordering::Equal,
305 });
306 grants
307}
308
309fn grant_from_value(value: &serde_json::Value) -> Option<CursorCreditGrant> {
310 let obj = value.as_object()?;
311 let total_cents = json_i64(obj, &["totalCents", "total_cents"])?;
312 let remaining_cents = json_i64(obj, &["remainingCents", "remaining_cents"])?;
313 if total_cents <= 0 || remaining_cents < 0 {
314 return None;
315 }
316 let expires_at = match json_field(obj, &["expiresAtMs", "expires_at_ms"]) {
317 None | Some(serde_json::Value::Null) => None,
318 Some(raw) => {
319 let ms = json_i64_value(raw)?;
320 if ms <= 0 {
321 None
322 } else {
323 Some(DateTime::from_timestamp_millis(ms)?)
324 }
325 }
326 };
327 let display_name = json_field(obj, &["displayName", "display_name"])
328 .and_then(serde_json::Value::as_str)
329 .unwrap_or("")
330 .trim()
331 .chars()
332 .take(160)
333 .collect();
334 Some(CursorCreditGrant {
335 remaining_cents,
336 total_cents,
337 expires_at,
338 display_name,
339 })
340}
341
342fn json_field<'a>(
343 obj: &'a serde_json::Map<String, serde_json::Value>,
344 names: &[&str],
345) -> Option<&'a serde_json::Value> {
346 names.iter().find_map(|name| obj.get(*name))
347}
348
349fn json_i64(obj: &serde_json::Map<String, serde_json::Value>, names: &[&str]) -> Option<i64> {
350 json_i64_value(json_field(obj, names)?)
351}
352
353fn json_i64_value(value: &serde_json::Value) -> Option<i64> {
356 match value {
357 serde_json::Value::Number(number) => {
358 if let Some(integer) = number.as_i64() {
359 return Some(integer);
360 }
361 let float = number.as_f64()?;
362 if !float.is_finite() || float.fract() != 0.0 || float.abs() > (1_i64 << 53) as f64 {
363 return None;
364 }
365 Some(float as i64)
366 }
367 serde_json::Value::String(text) => text.trim().parse().ok(),
368 _ => None,
369 }
370}
371
372#[cfg(test)]
373mod tests {
374 #[test]
375 fn on_demand_tolerates_missing_null_and_present() {
376 let missing: OnDemand = serde_json::from_str(r#"{"enabled":true}"#).unwrap();
380 assert_eq!(missing.used, None, "missing");
381 let null: OnDemand =
382 serde_json::from_str(r#"{"enabled":true,"used":null,"limit":null}"#).unwrap();
383 assert_eq!(
384 null.used, None,
385 "explicit null must not fail the whole parse"
386 );
387 let present: OnDemand =
388 serde_json::from_str(r#"{"enabled":true,"used":42,"limit":99}"#).unwrap();
389 assert_eq!(present.used, Some(42));
390 }
391 use super::*;
392 use chrono::TimeZone;
393
394 const SAMPLE: &str = r#"{
395 "billingCycleStart": "2026-07-04T00:35:51.000Z",
396 "billingCycleEnd": "2026-08-04T00:35:51.000Z",
397 "membershipType": "ultra",
398 "limitType": "user",
399 "isUnlimited": false,
400 "autoModelSelectedDisplayMessage": "You've used 98% of your included total usage",
401 "namedModelSelectedDisplayMessage": "You've used 100% of your included API usage",
402 "individualUsage": {
403 "plan": {
404 "enabled": true, "used": 40000, "limit": 40000, "remaining": 0,
405 "autoPercentUsed": 98.109, "apiPercentUsed": 100, "totalPercentUsed": 98.5128
406 },
407 "onDemand": { "enabled": true, "used": 1785, "limit": 35000, "remaining": 33215 }
408 },
409 "teamUsage": {}
410 }"#;
411
412 #[test]
413 fn parses_the_live_ultra_shape() {
414 let resp: UsageSummary = serde_json::from_str(SAMPLE).unwrap();
415 let snap = to_snapshot(resp).unwrap();
416 assert_eq!(snap.plan, "Ultra");
417 assert_eq!(snap.auto_pct, 98); assert_eq!(snap.api_pct, 100);
419 assert_eq!(snap.total_pct, 99); assert!(!snap.unlimited);
421 assert!(snap.on_demand_enabled);
422 assert_eq!(snap.on_demand_used_cents, Some(1785));
423 assert_eq!(snap.on_demand_limit_cents, Some(35000));
424 assert_eq!(
425 snap.reset_at,
426 Some(Utc.with_ymd_and_hms(2026, 8, 4, 0, 35, 51).unwrap())
427 );
428 assert_eq!(snap.worst_pct(), 100);
429 }
430
431 #[test]
432 fn over_allowance_percentage_is_kept_above_100() {
433 let raw = r#"{
434 "billingCycleEnd": "2026-08-04T00:00:00Z", "membershipType": "pro",
435 "individualUsage": { "plan": { "autoPercentUsed": 142.7, "apiPercentUsed": 5, "totalPercentUsed": 80 } }
436 }"#;
437 let snap = to_snapshot(serde_json::from_str(raw).unwrap()).unwrap();
438 assert_eq!(
439 snap.auto_pct, 143,
440 "an over-quota pool must not clamp to 100"
441 );
442 assert_eq!(snap.worst_pct(), 143);
443 }
444
445 #[test]
446 fn unlimited_plan_reports_no_pool_percentages() {
447 let raw = r#"{
448 "billingCycleEnd": "2026-08-04T00:00:00Z", "membershipType": "enterprise",
449 "isUnlimited": true
450 }"#;
451 let snap = to_snapshot(serde_json::from_str(raw).unwrap()).unwrap();
452 assert!(snap.unlimited);
453 assert_eq!(snap.worst_pct(), 0);
454 assert_eq!(snap.plan, "Enterprise");
455 }
456
457 #[test]
458 fn missing_individual_plan_is_schema_drift_not_zero() {
459 let raw = r#"{
461 "billingCycleEnd": "2026-08-04T00:00:00Z", "membershipType": "team",
462 "teamUsage": {}
463 }"#;
464 let err = to_snapshot(serde_json::from_str(raw).unwrap()).unwrap_err();
465 assert!(matches!(err, AppError::Schema(_)));
466 }
467
468 #[test]
469 fn missing_billing_cycle_end_is_a_parse_error() {
470 let raw = r#"{ "membershipType": "pro",
471 "individualUsage": { "plan": { "autoPercentUsed": 1, "apiPercentUsed": 2, "totalPercentUsed": 1 } } }"#;
472 assert!(serde_json::from_str::<UsageSummary>(raw).is_err());
474 }
475
476 #[test]
477 fn missing_total_percentage_is_a_parse_error_not_zero() {
478 let raw = r#"{
479 "billingCycleEnd": "2026-08-04T00:00:00Z", "membershipType": "pro",
480 "individualUsage": { "plan": { "autoPercentUsed": 1, "apiPercentUsed": 2 } }
481 }"#;
482 assert!(serde_json::from_str::<UsageSummary>(raw).is_err());
483 }
484
485 #[test]
486 fn non_finite_percentage_is_rejected() {
487 let resp = UsageSummary {
491 membership_type: "pro".into(),
492 is_unlimited: false,
493 billing_cycle_end: "2026-08-04T00:00:00Z".into(),
494 billing_cycle_start: None,
495 individual_usage: Some(IndividualUsage {
496 plan: Some(PlanUsage {
497 auto_percent_used: f64::NAN,
498 api_percent_used: 2.0,
499 total_percent_used: 1.0,
500 }),
501 on_demand: None,
502 }),
503 team_usage: None,
504 auto_model_selected_display_message: None,
505 named_model_selected_display_message: None,
506 };
507 assert!(matches!(to_snapshot(resp), Err(AppError::Schema(_))));
508 }
509
510 #[test]
511 fn percentage_too_large_for_the_snapshot_is_rejected() {
512 assert!(matches!(
513 pct("autoPercentUsed", f64::from(i32::MAX) + 1.0),
514 Err(AppError::Schema(_))
515 ));
516 }
517
518 const TEAM_SAMPLE: &str = r#"{
523 "billingCycleEnd": "2026-08-04T00:35:51.000Z",
524 "membershipType": "team",
525 "isUnlimited": false,
526 "autoModelSelectedDisplayMessage": "You've used 42% of your included total usage",
527 "namedModelSelectedDisplayMessage": "You've used 15% of your included API usage",
528 "teamUsage": { "onDemand": { "enabled": true } }
529 }"#;
530
531 #[test]
532 fn team_account_falls_back_to_display_message_percentages() {
533 let resp: UsageSummary = serde_json::from_str(TEAM_SAMPLE).unwrap();
534 let snap = to_snapshot(resp).unwrap();
535 assert_eq!(snap.plan, "Team (team)");
536 assert_eq!(snap.auto_pct, 42);
537 assert_eq!(snap.api_pct, 15);
538 assert_eq!(
539 snap.total_pct, 42,
540 "no blended-total signal exists; worst pool stands in"
541 );
542 assert!(!snap.unlimited);
543 assert!(
544 snap.on_demand_enabled,
545 "onDemand lives under teamUsage for a team account, not individualUsage"
546 );
547 assert_eq!(snap.worst_pct(), 42);
548 }
549
550 #[test]
551 fn team_account_with_only_one_parseable_message_is_still_schema_drift() {
552 let raw = r#"{
555 "billingCycleEnd": "2026-08-04T00:00:00Z", "membershipType": "team",
556 "autoModelSelectedDisplayMessage": "You've used 42% of your included total usage",
557 "namedModelSelectedDisplayMessage": "unavailable"
558 }"#;
559 let err = to_snapshot(serde_json::from_str(raw).unwrap()).unwrap_err();
560 assert!(matches!(err, AppError::Schema(_)));
561 }
562
563 #[test]
564 fn parse_percent_from_message_reads_leading_number_before_percent_sign() {
565 assert_eq!(
566 parse_percent_from_message("You've used 98% of your included total usage"),
567 Some(98.0)
568 );
569 assert_eq!(
570 parse_percent_from_message("You've used 100% of your included API usage"),
571 Some(100.0)
572 );
573 assert_eq!(parse_percent_from_message("no percent here"), None);
574 assert_eq!(parse_percent_from_message("unavailable"), None);
575 }
576
577 #[test]
578 fn credit_grants_accept_string_and_number_cents_and_skip_a_bad_sibling() {
579 let raw = r#"{
580 "grants": [
581 {"remainingCents": "2100", "totalCents": 2500, "expiresAtMs": "1793577600000", "displayName": " Promo "},
582 {"remainingCents": 100, "total_cents": 0},
583 {"remainingCents": -1, "totalCents": 500},
584 {"totalCents": 800, "remainingCents": 800, "expiresAtMs": 1790000000000}
585 ]
586 }"#;
587 let grants = parse_credit_grants(raw.as_bytes());
588 assert_eq!(grants.len(), 2);
589 assert_eq!(grants[0].remaining_cents, 800);
592 assert_eq!(grants[0].total_cents, 800);
593 assert!(grants[0].display_name.is_empty());
594 assert_eq!(grants[1].remaining_cents, 2100);
595 assert_eq!(grants[1].total_cents, 2500);
596 assert_eq!(grants[1].display_name, "Promo");
597 assert_eq!(
598 grants[1].expires_at,
599 DateTime::from_timestamp_millis(1_793_577_600_000)
600 );
601 }
602
603 #[test]
604 fn a_grant_with_nothing_left_is_kept() {
605 let raw = r#"{"grants":[{"remainingCents":0,"totalCents":2500,"displayName":"Power user grant"}]}"#;
606 let grants = parse_credit_grants(raw.as_bytes());
607 assert_eq!(grants.len(), 1);
608 assert_eq!(grants[0].remaining_cents, 0);
609 assert_eq!(grants[0].total_cents, 2500);
610 }
611
612 #[test]
613 fn credit_grants_without_a_list_are_none() {
614 assert!(parse_credit_grants(br#"{"grants":[]}"#).is_empty());
615 assert!(parse_credit_grants(br#"{}"#).is_empty());
616 assert!(parse_credit_grants(br#"{"grants":null}"#).is_empty());
617 assert!(parse_credit_grants(b"not json").is_empty());
618 }
619
620 #[test]
621 fn a_zero_expiry_is_a_grant_with_no_date() {
622 let raw = r#"{"grants":[{"remainingCents":500,"totalCents":500,"expiresAtMs":0}]}"#;
623 let grants = parse_credit_grants(raw.as_bytes());
624 assert_eq!(grants.len(), 1);
625 assert!(grants[0].expires_at.is_none());
626 }
627}