use chrono::{DateTime, Utc};
use serde::Deserialize;
use crate::error::{AppError, Result};
use crate::usage::CursorSnapshot;
#[derive(Debug, Clone, Deserialize)]
pub struct UsageSummary {
#[serde(rename = "membershipType", default)]
pub membership_type: String,
#[serde(rename = "isUnlimited", default)]
pub is_unlimited: bool,
#[serde(rename = "billingCycleEnd")]
pub billing_cycle_end: String,
#[serde(rename = "individualUsage")]
pub individual_usage: Option<IndividualUsage>,
#[serde(rename = "teamUsage", default)]
pub team_usage: Option<TeamUsage>,
#[serde(rename = "autoModelSelectedDisplayMessage", default)]
pub auto_model_selected_display_message: Option<String>,
#[serde(rename = "namedModelSelectedDisplayMessage", default)]
pub named_model_selected_display_message: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct IndividualUsage {
pub plan: Option<PlanUsage>,
#[serde(rename = "onDemand", default)]
pub on_demand: Option<OnDemand>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct TeamUsage {
#[serde(rename = "onDemand", default)]
pub on_demand: Option<OnDemand>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct PlanUsage {
#[serde(rename = "autoPercentUsed")]
pub auto_percent_used: f64,
#[serde(rename = "apiPercentUsed")]
pub api_percent_used: f64,
#[serde(rename = "totalPercentUsed")]
pub total_percent_used: f64,
}
#[derive(Debug, Clone, Deserialize)]
pub struct OnDemand {
#[serde(default)]
pub enabled: bool,
}
fn pct(field: &str, v: f64) -> Result<i32> {
if !v.is_finite() {
return Err(AppError::Schema(format!(
"cursor: `{field}` is not a finite number"
)));
}
let rounded = v.round().max(0.0);
if rounded > f64::from(i32::MAX) {
return Err(AppError::Schema(format!(
"cursor: `{field}` is too large to represent"
)));
}
Ok(rounded as i32)
}
pub fn to_snapshot(resp: UsageSummary) -> Result<CursorSnapshot> {
let reset_at = DateTime::parse_from_rfc3339(&resp.billing_cycle_end)
.map_err(|e| {
AppError::Schema(format!(
"cursor: `billingCycleEnd` is not RFC3339 ({:?}): {e}",
resp.billing_cycle_end
))
})?
.with_timezone(&Utc);
let plan = title_case(&resp.membership_type);
if resp.is_unlimited {
return Ok(CursorSnapshot {
plan,
auto_pct: 0,
api_pct: 0,
total_pct: 0,
unlimited: true,
on_demand_enabled: false,
reset_at: Some(reset_at),
});
}
let on_demand_enabled = resp
.individual_usage
.as_ref()
.and_then(|u| u.on_demand.as_ref())
.or_else(|| resp.team_usage.as_ref().and_then(|t| t.on_demand.as_ref()))
.map(|o| o.enabled)
.unwrap_or(false);
if let Some(plan_usage) = resp.individual_usage.as_ref().and_then(|u| u.plan.as_ref()) {
return Ok(CursorSnapshot {
plan,
auto_pct: pct("autoPercentUsed", plan_usage.auto_percent_used)?,
api_pct: pct("apiPercentUsed", plan_usage.api_percent_used)?,
total_pct: pct("totalPercentUsed", plan_usage.total_percent_used)?,
unlimited: false,
on_demand_enabled,
reset_at: Some(reset_at),
});
}
let team_pcts = resp
.auto_model_selected_display_message
.as_deref()
.and_then(parse_percent_from_message)
.zip(
resp.named_model_selected_display_message
.as_deref()
.and_then(parse_percent_from_message),
);
if let Some((auto_raw, api_raw)) = team_pcts {
let auto_pct = pct("autoModelSelectedDisplayMessage", auto_raw)?;
let api_pct = pct("namedModelSelectedDisplayMessage", api_raw)?;
return Ok(CursorSnapshot {
plan: format!("{plan} (team)"),
auto_pct,
api_pct,
total_pct: auto_pct.max(api_pct),
unlimited: false,
on_demand_enabled,
reset_at: Some(reset_at),
});
}
Err(AppError::Schema(
"cursor: response has no `individualUsage.plan` and no parseable team-usage \
display message (unrecognized team-account shape)"
.into(),
))
}
fn parse_percent_from_message(msg: &str) -> Option<f64> {
let pct_idx = msg.find('%')?;
let before = &msg[..pct_idx];
let start = before
.rfind(|c: char| !c.is_ascii_digit() && c != '.')
.map(|i| i + 1)
.unwrap_or(0);
before[start..].parse::<f64>().ok()
}
fn title_case(s: &str) -> String {
let mut chars = s.chars();
match chars.next() {
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
None => "Cursor".to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
const SAMPLE: &str = r#"{
"billingCycleStart": "2026-07-04T00:35:51.000Z",
"billingCycleEnd": "2026-08-04T00:35:51.000Z",
"membershipType": "ultra",
"limitType": "user",
"isUnlimited": false,
"autoModelSelectedDisplayMessage": "You've used 98% of your included total usage",
"namedModelSelectedDisplayMessage": "You've used 100% of your included API usage",
"individualUsage": {
"plan": {
"enabled": true, "used": 40000, "limit": 40000, "remaining": 0,
"autoPercentUsed": 98.109, "apiPercentUsed": 100, "totalPercentUsed": 98.5128
},
"onDemand": { "enabled": false, "used": 0, "limit": null, "remaining": null }
},
"teamUsage": {}
}"#;
#[test]
fn parses_the_live_ultra_shape() {
let resp: UsageSummary = serde_json::from_str(SAMPLE).unwrap();
let snap = to_snapshot(resp).unwrap();
assert_eq!(snap.plan, "Ultra");
assert_eq!(snap.auto_pct, 98); assert_eq!(snap.api_pct, 100);
assert_eq!(snap.total_pct, 99); assert!(!snap.unlimited);
assert!(!snap.on_demand_enabled);
assert_eq!(
snap.reset_at,
Some(Utc.with_ymd_and_hms(2026, 8, 4, 0, 35, 51).unwrap())
);
assert_eq!(snap.worst_pct(), 100);
}
#[test]
fn over_allowance_percentage_is_kept_above_100() {
let raw = r#"{
"billingCycleEnd": "2026-08-04T00:00:00Z", "membershipType": "pro",
"individualUsage": { "plan": { "autoPercentUsed": 142.7, "apiPercentUsed": 5, "totalPercentUsed": 80 } }
}"#;
let snap = to_snapshot(serde_json::from_str(raw).unwrap()).unwrap();
assert_eq!(
snap.auto_pct, 143,
"an over-quota pool must not clamp to 100"
);
assert_eq!(snap.worst_pct(), 143);
}
#[test]
fn unlimited_plan_reports_no_pool_percentages() {
let raw = r#"{
"billingCycleEnd": "2026-08-04T00:00:00Z", "membershipType": "enterprise",
"isUnlimited": true
}"#;
let snap = to_snapshot(serde_json::from_str(raw).unwrap()).unwrap();
assert!(snap.unlimited);
assert_eq!(snap.worst_pct(), 0);
assert_eq!(snap.plan, "Enterprise");
}
#[test]
fn missing_individual_plan_is_schema_drift_not_zero() {
let raw = r#"{
"billingCycleEnd": "2026-08-04T00:00:00Z", "membershipType": "team",
"teamUsage": {}
}"#;
let err = to_snapshot(serde_json::from_str(raw).unwrap()).unwrap_err();
assert!(matches!(err, AppError::Schema(_)));
}
#[test]
fn missing_billing_cycle_end_is_a_parse_error() {
let raw = r#"{ "membershipType": "pro",
"individualUsage": { "plan": { "autoPercentUsed": 1, "apiPercentUsed": 2, "totalPercentUsed": 1 } } }"#;
assert!(serde_json::from_str::<UsageSummary>(raw).is_err());
}
#[test]
fn missing_total_percentage_is_a_parse_error_not_zero() {
let raw = r#"{
"billingCycleEnd": "2026-08-04T00:00:00Z", "membershipType": "pro",
"individualUsage": { "plan": { "autoPercentUsed": 1, "apiPercentUsed": 2 } }
}"#;
assert!(serde_json::from_str::<UsageSummary>(raw).is_err());
}
#[test]
fn non_finite_percentage_is_rejected() {
let resp = UsageSummary {
membership_type: "pro".into(),
is_unlimited: false,
billing_cycle_end: "2026-08-04T00:00:00Z".into(),
individual_usage: Some(IndividualUsage {
plan: Some(PlanUsage {
auto_percent_used: f64::NAN,
api_percent_used: 2.0,
total_percent_used: 1.0,
}),
on_demand: None,
}),
team_usage: None,
auto_model_selected_display_message: None,
named_model_selected_display_message: None,
};
assert!(matches!(to_snapshot(resp), Err(AppError::Schema(_))));
}
#[test]
fn percentage_too_large_for_the_snapshot_is_rejected() {
assert!(matches!(
pct("autoPercentUsed", f64::from(i32::MAX) + 1.0),
Err(AppError::Schema(_))
));
}
const TEAM_SAMPLE: &str = r#"{
"billingCycleEnd": "2026-08-04T00:35:51.000Z",
"membershipType": "team",
"isUnlimited": false,
"autoModelSelectedDisplayMessage": "You've used 42% of your included total usage",
"namedModelSelectedDisplayMessage": "You've used 15% of your included API usage",
"teamUsage": { "onDemand": { "enabled": true } }
}"#;
#[test]
fn team_account_falls_back_to_display_message_percentages() {
let resp: UsageSummary = serde_json::from_str(TEAM_SAMPLE).unwrap();
let snap = to_snapshot(resp).unwrap();
assert_eq!(snap.plan, "Team (team)");
assert_eq!(snap.auto_pct, 42);
assert_eq!(snap.api_pct, 15);
assert_eq!(
snap.total_pct, 42,
"no blended-total signal exists; worst pool stands in"
);
assert!(!snap.unlimited);
assert!(
snap.on_demand_enabled,
"onDemand lives under teamUsage for a team account, not individualUsage"
);
assert_eq!(snap.worst_pct(), 42);
}
#[test]
fn team_account_with_only_one_parseable_message_is_still_schema_drift() {
let raw = r#"{
"billingCycleEnd": "2026-08-04T00:00:00Z", "membershipType": "team",
"autoModelSelectedDisplayMessage": "You've used 42% of your included total usage",
"namedModelSelectedDisplayMessage": "unavailable"
}"#;
let err = to_snapshot(serde_json::from_str(raw).unwrap()).unwrap_err();
assert!(matches!(err, AppError::Schema(_)));
}
#[test]
fn parse_percent_from_message_reads_leading_number_before_percent_sign() {
assert_eq!(
parse_percent_from_message("You've used 98% of your included total usage"),
Some(98.0)
);
assert_eq!(
parse_percent_from_message("You've used 100% of your included API usage"),
Some(100.0)
);
assert_eq!(parse_percent_from_message("no percent here"), None);
assert_eq!(parse_percent_from_message("unavailable"), None);
}
}