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 = "individualUsage")]
65 pub individual_usage: Option<IndividualUsage>,
66 #[serde(rename = "teamUsage", default)]
71 pub team_usage: Option<TeamUsage>,
72 #[serde(rename = "autoModelSelectedDisplayMessage", default)]
77 pub auto_model_selected_display_message: Option<String>,
78 #[serde(rename = "namedModelSelectedDisplayMessage", default)]
81 pub named_model_selected_display_message: Option<String>,
82}
83
84#[derive(Debug, Clone, Deserialize)]
85pub struct IndividualUsage {
86 pub plan: Option<PlanUsage>,
87 #[serde(rename = "onDemand", default)]
92 pub on_demand: Option<OnDemand>,
93}
94
95#[derive(Debug, Clone, Deserialize)]
96pub struct TeamUsage {
97 #[serde(rename = "onDemand", default)]
98 pub on_demand: Option<OnDemand>,
99}
100
101#[derive(Debug, Clone, Deserialize)]
102pub struct PlanUsage {
103 #[serde(rename = "autoPercentUsed")]
105 pub auto_percent_used: f64,
106 #[serde(rename = "apiPercentUsed")]
108 pub api_percent_used: f64,
109 #[serde(rename = "totalPercentUsed")]
113 pub total_percent_used: f64,
114}
115
116#[derive(Debug, Clone, Deserialize)]
117pub struct OnDemand {
118 #[serde(default)]
119 pub enabled: bool,
120}
121
122fn pct(field: &str, v: f64) -> Result<i32> {
127 if !v.is_finite() {
128 return Err(AppError::Schema(format!(
129 "cursor: `{field}` is not a finite number"
130 )));
131 }
132 let rounded = v.round().max(0.0);
137 if rounded > f64::from(i32::MAX) {
138 return Err(AppError::Schema(format!(
139 "cursor: `{field}` is too large to represent"
140 )));
141 }
142 Ok(rounded as i32)
143}
144
145pub fn to_snapshot(resp: UsageSummary) -> Result<CursorSnapshot> {
146 let reset_at = DateTime::parse_from_rfc3339(&resp.billing_cycle_end)
147 .map_err(|e| {
148 AppError::Schema(format!(
149 "cursor: `billingCycleEnd` is not RFC3339 ({:?}): {e}",
150 resp.billing_cycle_end
151 ))
152 })?
153 .with_timezone(&Utc);
154
155 let plan = title_case(&resp.membership_type);
156
157 if resp.is_unlimited {
161 return Ok(CursorSnapshot {
162 plan,
163 auto_pct: 0,
164 api_pct: 0,
165 total_pct: 0,
166 unlimited: true,
167 on_demand_enabled: false,
168 reset_at: Some(reset_at),
169 });
170 }
171
172 let on_demand_enabled = resp
176 .individual_usage
177 .as_ref()
178 .and_then(|u| u.on_demand.as_ref())
179 .or_else(|| resp.team_usage.as_ref().and_then(|t| t.on_demand.as_ref()))
180 .map(|o| o.enabled)
181 .unwrap_or(false);
182
183 if let Some(plan_usage) = resp.individual_usage.as_ref().and_then(|u| u.plan.as_ref()) {
184 return Ok(CursorSnapshot {
185 plan,
186 auto_pct: pct("autoPercentUsed", plan_usage.auto_percent_used)?,
187 api_pct: pct("apiPercentUsed", plan_usage.api_percent_used)?,
188 total_pct: pct("totalPercentUsed", plan_usage.total_percent_used)?,
189 unlimited: false,
190 on_demand_enabled,
191 reset_at: Some(reset_at),
192 });
193 }
194
195 let team_pcts = resp
199 .auto_model_selected_display_message
200 .as_deref()
201 .and_then(parse_percent_from_message)
202 .zip(
203 resp.named_model_selected_display_message
204 .as_deref()
205 .and_then(parse_percent_from_message),
206 );
207 if let Some((auto_raw, api_raw)) = team_pcts {
208 let auto_pct = pct("autoModelSelectedDisplayMessage", auto_raw)?;
209 let api_pct = pct("namedModelSelectedDisplayMessage", api_raw)?;
210 return Ok(CursorSnapshot {
211 plan: format!("{plan} (team)"),
215 auto_pct,
216 api_pct,
217 total_pct: auto_pct.max(api_pct),
221 unlimited: false,
222 on_demand_enabled,
223 reset_at: Some(reset_at),
224 });
225 }
226
227 Err(AppError::Schema(
228 "cursor: response has no `individualUsage.plan` and no parseable team-usage \
229 display message (unrecognized team-account shape)"
230 .into(),
231 ))
232}
233
234fn parse_percent_from_message(msg: &str) -> Option<f64> {
239 let pct_idx = msg.find('%')?;
240 let before = &msg[..pct_idx];
241 let start = before
242 .rfind(|c: char| !c.is_ascii_digit() && c != '.')
243 .map(|i| i + 1)
244 .unwrap_or(0);
245 before[start..].parse::<f64>().ok()
246}
247
248fn title_case(s: &str) -> String {
251 let mut chars = s.chars();
252 match chars.next() {
253 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
254 None => "Cursor".to_string(),
255 }
256}
257
258#[cfg(test)]
259mod tests {
260 use super::*;
261 use chrono::TimeZone;
262
263 const SAMPLE: &str = r#"{
264 "billingCycleStart": "2026-07-04T00:35:51.000Z",
265 "billingCycleEnd": "2026-08-04T00:35:51.000Z",
266 "membershipType": "ultra",
267 "limitType": "user",
268 "isUnlimited": false,
269 "autoModelSelectedDisplayMessage": "You've used 98% of your included total usage",
270 "namedModelSelectedDisplayMessage": "You've used 100% of your included API usage",
271 "individualUsage": {
272 "plan": {
273 "enabled": true, "used": 40000, "limit": 40000, "remaining": 0,
274 "autoPercentUsed": 98.109, "apiPercentUsed": 100, "totalPercentUsed": 98.5128
275 },
276 "onDemand": { "enabled": false, "used": 0, "limit": null, "remaining": null }
277 },
278 "teamUsage": {}
279 }"#;
280
281 #[test]
282 fn parses_the_live_ultra_shape() {
283 let resp: UsageSummary = serde_json::from_str(SAMPLE).unwrap();
284 let snap = to_snapshot(resp).unwrap();
285 assert_eq!(snap.plan, "Ultra");
286 assert_eq!(snap.auto_pct, 98); assert_eq!(snap.api_pct, 100);
288 assert_eq!(snap.total_pct, 99); assert!(!snap.unlimited);
290 assert!(!snap.on_demand_enabled);
291 assert_eq!(
292 snap.reset_at,
293 Some(Utc.with_ymd_and_hms(2026, 8, 4, 0, 35, 51).unwrap())
294 );
295 assert_eq!(snap.worst_pct(), 100);
296 }
297
298 #[test]
299 fn over_allowance_percentage_is_kept_above_100() {
300 let raw = r#"{
301 "billingCycleEnd": "2026-08-04T00:00:00Z", "membershipType": "pro",
302 "individualUsage": { "plan": { "autoPercentUsed": 142.7, "apiPercentUsed": 5, "totalPercentUsed": 80 } }
303 }"#;
304 let snap = to_snapshot(serde_json::from_str(raw).unwrap()).unwrap();
305 assert_eq!(
306 snap.auto_pct, 143,
307 "an over-quota pool must not clamp to 100"
308 );
309 assert_eq!(snap.worst_pct(), 143);
310 }
311
312 #[test]
313 fn unlimited_plan_reports_no_pool_percentages() {
314 let raw = r#"{
315 "billingCycleEnd": "2026-08-04T00:00:00Z", "membershipType": "enterprise",
316 "isUnlimited": true
317 }"#;
318 let snap = to_snapshot(serde_json::from_str(raw).unwrap()).unwrap();
319 assert!(snap.unlimited);
320 assert_eq!(snap.worst_pct(), 0);
321 assert_eq!(snap.plan, "Enterprise");
322 }
323
324 #[test]
325 fn missing_individual_plan_is_schema_drift_not_zero() {
326 let raw = r#"{
328 "billingCycleEnd": "2026-08-04T00:00:00Z", "membershipType": "team",
329 "teamUsage": {}
330 }"#;
331 let err = to_snapshot(serde_json::from_str(raw).unwrap()).unwrap_err();
332 assert!(matches!(err, AppError::Schema(_)));
333 }
334
335 #[test]
336 fn missing_billing_cycle_end_is_a_parse_error() {
337 let raw = r#"{ "membershipType": "pro",
338 "individualUsage": { "plan": { "autoPercentUsed": 1, "apiPercentUsed": 2, "totalPercentUsed": 1 } } }"#;
339 assert!(serde_json::from_str::<UsageSummary>(raw).is_err());
341 }
342
343 #[test]
344 fn missing_total_percentage_is_a_parse_error_not_zero() {
345 let raw = r#"{
346 "billingCycleEnd": "2026-08-04T00:00:00Z", "membershipType": "pro",
347 "individualUsage": { "plan": { "autoPercentUsed": 1, "apiPercentUsed": 2 } }
348 }"#;
349 assert!(serde_json::from_str::<UsageSummary>(raw).is_err());
350 }
351
352 #[test]
353 fn non_finite_percentage_is_rejected() {
354 let resp = UsageSummary {
358 membership_type: "pro".into(),
359 is_unlimited: false,
360 billing_cycle_end: "2026-08-04T00:00:00Z".into(),
361 individual_usage: Some(IndividualUsage {
362 plan: Some(PlanUsage {
363 auto_percent_used: f64::NAN,
364 api_percent_used: 2.0,
365 total_percent_used: 1.0,
366 }),
367 on_demand: None,
368 }),
369 team_usage: None,
370 auto_model_selected_display_message: None,
371 named_model_selected_display_message: None,
372 };
373 assert!(matches!(to_snapshot(resp), Err(AppError::Schema(_))));
374 }
375
376 #[test]
377 fn percentage_too_large_for_the_snapshot_is_rejected() {
378 assert!(matches!(
379 pct("autoPercentUsed", f64::from(i32::MAX) + 1.0),
380 Err(AppError::Schema(_))
381 ));
382 }
383
384 const TEAM_SAMPLE: &str = r#"{
389 "billingCycleEnd": "2026-08-04T00:35:51.000Z",
390 "membershipType": "team",
391 "isUnlimited": false,
392 "autoModelSelectedDisplayMessage": "You've used 42% of your included total usage",
393 "namedModelSelectedDisplayMessage": "You've used 15% of your included API usage",
394 "teamUsage": { "onDemand": { "enabled": true } }
395 }"#;
396
397 #[test]
398 fn team_account_falls_back_to_display_message_percentages() {
399 let resp: UsageSummary = serde_json::from_str(TEAM_SAMPLE).unwrap();
400 let snap = to_snapshot(resp).unwrap();
401 assert_eq!(snap.plan, "Team (team)");
402 assert_eq!(snap.auto_pct, 42);
403 assert_eq!(snap.api_pct, 15);
404 assert_eq!(
405 snap.total_pct, 42,
406 "no blended-total signal exists; worst pool stands in"
407 );
408 assert!(!snap.unlimited);
409 assert!(
410 snap.on_demand_enabled,
411 "onDemand lives under teamUsage for a team account, not individualUsage"
412 );
413 assert_eq!(snap.worst_pct(), 42);
414 }
415
416 #[test]
417 fn team_account_with_only_one_parseable_message_is_still_schema_drift() {
418 let raw = r#"{
421 "billingCycleEnd": "2026-08-04T00:00:00Z", "membershipType": "team",
422 "autoModelSelectedDisplayMessage": "You've used 42% of your included total usage",
423 "namedModelSelectedDisplayMessage": "unavailable"
424 }"#;
425 let err = to_snapshot(serde_json::from_str(raw).unwrap()).unwrap_err();
426 assert!(matches!(err, AppError::Schema(_)));
427 }
428
429 #[test]
430 fn parse_percent_from_message_reads_leading_number_before_percent_sign() {
431 assert_eq!(
432 parse_percent_from_message("You've used 98% of your included total usage"),
433 Some(98.0)
434 );
435 assert_eq!(
436 parse_percent_from_message("You've used 100% of your included API usage"),
437 Some(100.0)
438 );
439 assert_eq!(parse_percent_from_message("no percent here"), None);
440 assert_eq!(parse_percent_from_message("unavailable"), None);
441 }
442}