1use chrono::{DateTime, Utc};
48use serde::Deserialize;
49
50use crate::error::{AppError, Result};
51use crate::usage::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}
125
126fn pct(field: &str, v: f64) -> Result<i32> {
131 if !v.is_finite() {
132 return Err(AppError::Schema(format!(
133 "cursor: `{field}` is not a finite number"
134 )));
135 }
136 let rounded = v.round().max(0.0);
141 if rounded > f64::from(i32::MAX) {
142 return Err(AppError::Schema(format!(
143 "cursor: `{field}` is too large to represent"
144 )));
145 }
146 Ok(rounded as i32)
147}
148
149pub fn to_snapshot(resp: UsageSummary) -> Result<CursorSnapshot> {
150 let reset_at = DateTime::parse_from_rfc3339(&resp.billing_cycle_end)
151 .map_err(|e| {
152 AppError::Schema(format!(
153 "cursor: `billingCycleEnd` is not RFC3339 ({:?}): {e}",
154 resp.billing_cycle_end
155 ))
156 })?
157 .with_timezone(&Utc);
158 let cycle_start = resp
160 .billing_cycle_start
161 .as_deref()
162 .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
163 .map(|dt| dt.with_timezone(&Utc));
164
165 let plan = title_case(&resp.membership_type);
166
167 if resp.is_unlimited {
171 return Ok(CursorSnapshot {
172 plan,
173 auto_pct: 0,
174 api_pct: 0,
175 total_pct: 0,
176 unlimited: true,
177 on_demand_enabled: false,
178 reset_at: Some(reset_at),
179 cycle_start,
180 });
181 }
182
183 let on_demand_enabled = resp
187 .individual_usage
188 .as_ref()
189 .and_then(|u| u.on_demand.as_ref())
190 .or_else(|| resp.team_usage.as_ref().and_then(|t| t.on_demand.as_ref()))
191 .map(|o| o.enabled)
192 .unwrap_or(false);
193
194 if let Some(plan_usage) = resp.individual_usage.as_ref().and_then(|u| u.plan.as_ref()) {
195 return Ok(CursorSnapshot {
196 plan,
197 auto_pct: pct("autoPercentUsed", plan_usage.auto_percent_used)?,
198 api_pct: pct("apiPercentUsed", plan_usage.api_percent_used)?,
199 total_pct: pct("totalPercentUsed", plan_usage.total_percent_used)?,
200 unlimited: false,
201 on_demand_enabled,
202 reset_at: Some(reset_at),
203 cycle_start,
204 });
205 }
206
207 let team_pcts = resp
211 .auto_model_selected_display_message
212 .as_deref()
213 .and_then(parse_percent_from_message)
214 .zip(
215 resp.named_model_selected_display_message
216 .as_deref()
217 .and_then(parse_percent_from_message),
218 );
219 if let Some((auto_raw, api_raw)) = team_pcts {
220 let auto_pct = pct("autoModelSelectedDisplayMessage", auto_raw)?;
221 let api_pct = pct("namedModelSelectedDisplayMessage", api_raw)?;
222 return Ok(CursorSnapshot {
223 plan: format!("{plan} (team)"),
227 auto_pct,
228 api_pct,
229 total_pct: auto_pct.max(api_pct),
233 unlimited: false,
234 on_demand_enabled,
235 reset_at: Some(reset_at),
236 cycle_start,
237 });
238 }
239
240 Err(AppError::Schema(
241 "cursor: response has no `individualUsage.plan` and no parseable team-usage \
242 display message (unrecognized team-account shape)"
243 .into(),
244 ))
245}
246
247fn parse_percent_from_message(msg: &str) -> Option<f64> {
252 let pct_idx = msg.find('%')?;
253 let before = &msg[..pct_idx];
254 let start = before
255 .rfind(|c: char| !c.is_ascii_digit() && c != '.')
256 .map(|i| i + 1)
257 .unwrap_or(0);
258 before[start..].parse::<f64>().ok()
259}
260
261fn title_case(s: &str) -> String {
264 let mut chars = s.chars();
265 match chars.next() {
266 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
267 None => "Cursor".to_string(),
268 }
269}
270
271#[cfg(test)]
272mod tests {
273 use super::*;
274 use chrono::TimeZone;
275
276 const SAMPLE: &str = r#"{
277 "billingCycleStart": "2026-07-04T00:35:51.000Z",
278 "billingCycleEnd": "2026-08-04T00:35:51.000Z",
279 "membershipType": "ultra",
280 "limitType": "user",
281 "isUnlimited": false,
282 "autoModelSelectedDisplayMessage": "You've used 98% of your included total usage",
283 "namedModelSelectedDisplayMessage": "You've used 100% of your included API usage",
284 "individualUsage": {
285 "plan": {
286 "enabled": true, "used": 40000, "limit": 40000, "remaining": 0,
287 "autoPercentUsed": 98.109, "apiPercentUsed": 100, "totalPercentUsed": 98.5128
288 },
289 "onDemand": { "enabled": false, "used": 0, "limit": null, "remaining": null }
290 },
291 "teamUsage": {}
292 }"#;
293
294 #[test]
295 fn parses_the_live_ultra_shape() {
296 let resp: UsageSummary = serde_json::from_str(SAMPLE).unwrap();
297 let snap = to_snapshot(resp).unwrap();
298 assert_eq!(snap.plan, "Ultra");
299 assert_eq!(snap.auto_pct, 98); assert_eq!(snap.api_pct, 100);
301 assert_eq!(snap.total_pct, 99); assert!(!snap.unlimited);
303 assert!(!snap.on_demand_enabled);
304 assert_eq!(
305 snap.reset_at,
306 Some(Utc.with_ymd_and_hms(2026, 8, 4, 0, 35, 51).unwrap())
307 );
308 assert_eq!(snap.worst_pct(), 100);
309 }
310
311 #[test]
312 fn over_allowance_percentage_is_kept_above_100() {
313 let raw = r#"{
314 "billingCycleEnd": "2026-08-04T00:00:00Z", "membershipType": "pro",
315 "individualUsage": { "plan": { "autoPercentUsed": 142.7, "apiPercentUsed": 5, "totalPercentUsed": 80 } }
316 }"#;
317 let snap = to_snapshot(serde_json::from_str(raw).unwrap()).unwrap();
318 assert_eq!(
319 snap.auto_pct, 143,
320 "an over-quota pool must not clamp to 100"
321 );
322 assert_eq!(snap.worst_pct(), 143);
323 }
324
325 #[test]
326 fn unlimited_plan_reports_no_pool_percentages() {
327 let raw = r#"{
328 "billingCycleEnd": "2026-08-04T00:00:00Z", "membershipType": "enterprise",
329 "isUnlimited": true
330 }"#;
331 let snap = to_snapshot(serde_json::from_str(raw).unwrap()).unwrap();
332 assert!(snap.unlimited);
333 assert_eq!(snap.worst_pct(), 0);
334 assert_eq!(snap.plan, "Enterprise");
335 }
336
337 #[test]
338 fn missing_individual_plan_is_schema_drift_not_zero() {
339 let raw = r#"{
341 "billingCycleEnd": "2026-08-04T00:00:00Z", "membershipType": "team",
342 "teamUsage": {}
343 }"#;
344 let err = to_snapshot(serde_json::from_str(raw).unwrap()).unwrap_err();
345 assert!(matches!(err, AppError::Schema(_)));
346 }
347
348 #[test]
349 fn missing_billing_cycle_end_is_a_parse_error() {
350 let raw = r#"{ "membershipType": "pro",
351 "individualUsage": { "plan": { "autoPercentUsed": 1, "apiPercentUsed": 2, "totalPercentUsed": 1 } } }"#;
352 assert!(serde_json::from_str::<UsageSummary>(raw).is_err());
354 }
355
356 #[test]
357 fn missing_total_percentage_is_a_parse_error_not_zero() {
358 let raw = r#"{
359 "billingCycleEnd": "2026-08-04T00:00:00Z", "membershipType": "pro",
360 "individualUsage": { "plan": { "autoPercentUsed": 1, "apiPercentUsed": 2 } }
361 }"#;
362 assert!(serde_json::from_str::<UsageSummary>(raw).is_err());
363 }
364
365 #[test]
366 fn non_finite_percentage_is_rejected() {
367 let resp = UsageSummary {
371 membership_type: "pro".into(),
372 is_unlimited: false,
373 billing_cycle_end: "2026-08-04T00:00:00Z".into(),
374 billing_cycle_start: None,
375 individual_usage: Some(IndividualUsage {
376 plan: Some(PlanUsage {
377 auto_percent_used: f64::NAN,
378 api_percent_used: 2.0,
379 total_percent_used: 1.0,
380 }),
381 on_demand: None,
382 }),
383 team_usage: None,
384 auto_model_selected_display_message: None,
385 named_model_selected_display_message: None,
386 };
387 assert!(matches!(to_snapshot(resp), Err(AppError::Schema(_))));
388 }
389
390 #[test]
391 fn percentage_too_large_for_the_snapshot_is_rejected() {
392 assert!(matches!(
393 pct("autoPercentUsed", f64::from(i32::MAX) + 1.0),
394 Err(AppError::Schema(_))
395 ));
396 }
397
398 const TEAM_SAMPLE: &str = r#"{
403 "billingCycleEnd": "2026-08-04T00:35:51.000Z",
404 "membershipType": "team",
405 "isUnlimited": false,
406 "autoModelSelectedDisplayMessage": "You've used 42% of your included total usage",
407 "namedModelSelectedDisplayMessage": "You've used 15% of your included API usage",
408 "teamUsage": { "onDemand": { "enabled": true } }
409 }"#;
410
411 #[test]
412 fn team_account_falls_back_to_display_message_percentages() {
413 let resp: UsageSummary = serde_json::from_str(TEAM_SAMPLE).unwrap();
414 let snap = to_snapshot(resp).unwrap();
415 assert_eq!(snap.plan, "Team (team)");
416 assert_eq!(snap.auto_pct, 42);
417 assert_eq!(snap.api_pct, 15);
418 assert_eq!(
419 snap.total_pct, 42,
420 "no blended-total signal exists; worst pool stands in"
421 );
422 assert!(!snap.unlimited);
423 assert!(
424 snap.on_demand_enabled,
425 "onDemand lives under teamUsage for a team account, not individualUsage"
426 );
427 assert_eq!(snap.worst_pct(), 42);
428 }
429
430 #[test]
431 fn team_account_with_only_one_parseable_message_is_still_schema_drift() {
432 let raw = r#"{
435 "billingCycleEnd": "2026-08-04T00:00:00Z", "membershipType": "team",
436 "autoModelSelectedDisplayMessage": "You've used 42% of your included total usage",
437 "namedModelSelectedDisplayMessage": "unavailable"
438 }"#;
439 let err = to_snapshot(serde_json::from_str(raw).unwrap()).unwrap_err();
440 assert!(matches!(err, AppError::Schema(_)));
441 }
442
443 #[test]
444 fn parse_percent_from_message_reads_leading_number_before_percent_sign() {
445 assert_eq!(
446 parse_percent_from_message("You've used 98% of your included total usage"),
447 Some(98.0)
448 );
449 assert_eq!(
450 parse_percent_from_message("You've used 100% of your included API usage"),
451 Some(100.0)
452 );
453 assert_eq!(parse_percent_from_message("no percent here"), None);
454 assert_eq!(parse_percent_from_message("unavailable"), None);
455 }
456}