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 #[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 });
187 }
188
189 let on_demand = resp
193 .individual_usage
194 .as_ref()
195 .and_then(|u| u.on_demand.as_ref())
196 .or_else(|| resp.team_usage.as_ref().and_then(|t| t.on_demand.as_ref()));
197 let on_demand_enabled = on_demand.is_some_and(|o| o.enabled);
198 let on_demand_used_cents = on_demand.and_then(|o| o.used).filter(|v| *v >= 0);
199 let on_demand_limit_cents = on_demand.and_then(|o| o.limit).filter(|v| *v >= 0);
200
201 if let Some(plan_usage) = resp.individual_usage.as_ref().and_then(|u| u.plan.as_ref()) {
202 return Ok(CursorSnapshot {
203 plan,
204 auto_pct: pct("autoPercentUsed", plan_usage.auto_percent_used)?,
205 api_pct: pct("apiPercentUsed", plan_usage.api_percent_used)?,
206 total_pct: pct("totalPercentUsed", plan_usage.total_percent_used)?,
207 unlimited: false,
208 on_demand_enabled,
209 on_demand_used_cents,
210 on_demand_limit_cents,
211 reset_at: Some(reset_at),
212 cycle_start,
213 });
214 }
215
216 let team_pcts = resp
220 .auto_model_selected_display_message
221 .as_deref()
222 .and_then(parse_percent_from_message)
223 .zip(
224 resp.named_model_selected_display_message
225 .as_deref()
226 .and_then(parse_percent_from_message),
227 );
228 if let Some((auto_raw, api_raw)) = team_pcts {
229 let auto_pct = pct("autoModelSelectedDisplayMessage", auto_raw)?;
230 let api_pct = pct("namedModelSelectedDisplayMessage", api_raw)?;
231 return Ok(CursorSnapshot {
232 plan: format!("{plan} (team)"),
236 auto_pct,
237 api_pct,
238 total_pct: auto_pct.max(api_pct),
242 unlimited: false,
243 on_demand_enabled,
244 on_demand_used_cents,
245 on_demand_limit_cents,
246 reset_at: Some(reset_at),
247 cycle_start,
248 });
249 }
250
251 Err(AppError::Schema(
252 "cursor: response has no `individualUsage.plan` and no parseable team-usage \
253 display message (unrecognized team-account shape)"
254 .into(),
255 ))
256}
257
258fn parse_percent_from_message(msg: &str) -> Option<f64> {
263 let pct_idx = msg.find('%')?;
264 let before = &msg[..pct_idx];
265 let start = before
266 .rfind(|c: char| !c.is_ascii_digit() && c != '.')
267 .map(|i| i + 1)
268 .unwrap_or(0);
269 before[start..].parse::<f64>().ok()
270}
271
272fn title_case(s: &str) -> String {
275 let mut chars = s.chars();
276 match chars.next() {
277 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
278 None => "Cursor".to_string(),
279 }
280}
281
282#[cfg(test)]
283mod tests {
284 #[test]
285 fn on_demand_tolerates_missing_null_and_present() {
286 let missing: OnDemand = serde_json::from_str(r#"{"enabled":true}"#).unwrap();
290 assert_eq!(missing.used, None, "missing");
291 let null: OnDemand =
292 serde_json::from_str(r#"{"enabled":true,"used":null,"limit":null}"#).unwrap();
293 assert_eq!(
294 null.used, None,
295 "explicit null must not fail the whole parse"
296 );
297 let present: OnDemand =
298 serde_json::from_str(r#"{"enabled":true,"used":42,"limit":99}"#).unwrap();
299 assert_eq!(present.used, Some(42));
300 }
301 use super::*;
302 use chrono::TimeZone;
303
304 const SAMPLE: &str = r#"{
305 "billingCycleStart": "2026-07-04T00:35:51.000Z",
306 "billingCycleEnd": "2026-08-04T00:35:51.000Z",
307 "membershipType": "ultra",
308 "limitType": "user",
309 "isUnlimited": false,
310 "autoModelSelectedDisplayMessage": "You've used 98% of your included total usage",
311 "namedModelSelectedDisplayMessage": "You've used 100% of your included API usage",
312 "individualUsage": {
313 "plan": {
314 "enabled": true, "used": 40000, "limit": 40000, "remaining": 0,
315 "autoPercentUsed": 98.109, "apiPercentUsed": 100, "totalPercentUsed": 98.5128
316 },
317 "onDemand": { "enabled": true, "used": 1785, "limit": 35000, "remaining": 33215 }
318 },
319 "teamUsage": {}
320 }"#;
321
322 #[test]
323 fn parses_the_live_ultra_shape() {
324 let resp: UsageSummary = serde_json::from_str(SAMPLE).unwrap();
325 let snap = to_snapshot(resp).unwrap();
326 assert_eq!(snap.plan, "Ultra");
327 assert_eq!(snap.auto_pct, 98); assert_eq!(snap.api_pct, 100);
329 assert_eq!(snap.total_pct, 99); assert!(!snap.unlimited);
331 assert!(snap.on_demand_enabled);
332 assert_eq!(snap.on_demand_used_cents, Some(1785));
333 assert_eq!(snap.on_demand_limit_cents, Some(35000));
334 assert_eq!(
335 snap.reset_at,
336 Some(Utc.with_ymd_and_hms(2026, 8, 4, 0, 35, 51).unwrap())
337 );
338 assert_eq!(snap.worst_pct(), 100);
339 }
340
341 #[test]
342 fn over_allowance_percentage_is_kept_above_100() {
343 let raw = r#"{
344 "billingCycleEnd": "2026-08-04T00:00:00Z", "membershipType": "pro",
345 "individualUsage": { "plan": { "autoPercentUsed": 142.7, "apiPercentUsed": 5, "totalPercentUsed": 80 } }
346 }"#;
347 let snap = to_snapshot(serde_json::from_str(raw).unwrap()).unwrap();
348 assert_eq!(
349 snap.auto_pct, 143,
350 "an over-quota pool must not clamp to 100"
351 );
352 assert_eq!(snap.worst_pct(), 143);
353 }
354
355 #[test]
356 fn unlimited_plan_reports_no_pool_percentages() {
357 let raw = r#"{
358 "billingCycleEnd": "2026-08-04T00:00:00Z", "membershipType": "enterprise",
359 "isUnlimited": true
360 }"#;
361 let snap = to_snapshot(serde_json::from_str(raw).unwrap()).unwrap();
362 assert!(snap.unlimited);
363 assert_eq!(snap.worst_pct(), 0);
364 assert_eq!(snap.plan, "Enterprise");
365 }
366
367 #[test]
368 fn missing_individual_plan_is_schema_drift_not_zero() {
369 let raw = r#"{
371 "billingCycleEnd": "2026-08-04T00:00:00Z", "membershipType": "team",
372 "teamUsage": {}
373 }"#;
374 let err = to_snapshot(serde_json::from_str(raw).unwrap()).unwrap_err();
375 assert!(matches!(err, AppError::Schema(_)));
376 }
377
378 #[test]
379 fn missing_billing_cycle_end_is_a_parse_error() {
380 let raw = r#"{ "membershipType": "pro",
381 "individualUsage": { "plan": { "autoPercentUsed": 1, "apiPercentUsed": 2, "totalPercentUsed": 1 } } }"#;
382 assert!(serde_json::from_str::<UsageSummary>(raw).is_err());
384 }
385
386 #[test]
387 fn missing_total_percentage_is_a_parse_error_not_zero() {
388 let raw = r#"{
389 "billingCycleEnd": "2026-08-04T00:00:00Z", "membershipType": "pro",
390 "individualUsage": { "plan": { "autoPercentUsed": 1, "apiPercentUsed": 2 } }
391 }"#;
392 assert!(serde_json::from_str::<UsageSummary>(raw).is_err());
393 }
394
395 #[test]
396 fn non_finite_percentage_is_rejected() {
397 let resp = UsageSummary {
401 membership_type: "pro".into(),
402 is_unlimited: false,
403 billing_cycle_end: "2026-08-04T00:00:00Z".into(),
404 billing_cycle_start: None,
405 individual_usage: Some(IndividualUsage {
406 plan: Some(PlanUsage {
407 auto_percent_used: f64::NAN,
408 api_percent_used: 2.0,
409 total_percent_used: 1.0,
410 }),
411 on_demand: None,
412 }),
413 team_usage: None,
414 auto_model_selected_display_message: None,
415 named_model_selected_display_message: None,
416 };
417 assert!(matches!(to_snapshot(resp), Err(AppError::Schema(_))));
418 }
419
420 #[test]
421 fn percentage_too_large_for_the_snapshot_is_rejected() {
422 assert!(matches!(
423 pct("autoPercentUsed", f64::from(i32::MAX) + 1.0),
424 Err(AppError::Schema(_))
425 ));
426 }
427
428 const TEAM_SAMPLE: &str = r#"{
433 "billingCycleEnd": "2026-08-04T00:35:51.000Z",
434 "membershipType": "team",
435 "isUnlimited": false,
436 "autoModelSelectedDisplayMessage": "You've used 42% of your included total usage",
437 "namedModelSelectedDisplayMessage": "You've used 15% of your included API usage",
438 "teamUsage": { "onDemand": { "enabled": true } }
439 }"#;
440
441 #[test]
442 fn team_account_falls_back_to_display_message_percentages() {
443 let resp: UsageSummary = serde_json::from_str(TEAM_SAMPLE).unwrap();
444 let snap = to_snapshot(resp).unwrap();
445 assert_eq!(snap.plan, "Team (team)");
446 assert_eq!(snap.auto_pct, 42);
447 assert_eq!(snap.api_pct, 15);
448 assert_eq!(
449 snap.total_pct, 42,
450 "no blended-total signal exists; worst pool stands in"
451 );
452 assert!(!snap.unlimited);
453 assert!(
454 snap.on_demand_enabled,
455 "onDemand lives under teamUsage for a team account, not individualUsage"
456 );
457 assert_eq!(snap.worst_pct(), 42);
458 }
459
460 #[test]
461 fn team_account_with_only_one_parseable_message_is_still_schema_drift() {
462 let raw = r#"{
465 "billingCycleEnd": "2026-08-04T00:00:00Z", "membershipType": "team",
466 "autoModelSelectedDisplayMessage": "You've used 42% of your included total usage",
467 "namedModelSelectedDisplayMessage": "unavailable"
468 }"#;
469 let err = to_snapshot(serde_json::from_str(raw).unwrap()).unwrap_err();
470 assert!(matches!(err, AppError::Schema(_)));
471 }
472
473 #[test]
474 fn parse_percent_from_message_reads_leading_number_before_percent_sign() {
475 assert_eq!(
476 parse_percent_from_message("You've used 98% of your included total usage"),
477 Some(98.0)
478 );
479 assert_eq!(
480 parse_percent_from_message("You've used 100% of your included API usage"),
481 Some(100.0)
482 );
483 assert_eq!(parse_percent_from_message("no percent here"), None);
484 assert_eq!(parse_percent_from_message("unavailable"), None);
485 }
486}