1use async_trait::async_trait;
2use chrono::{DateTime, Utc};
3
4use crate::error::SpendPanelError;
5use crate::model::{CreditsSnapshot, PlanInfo, RateWindow, UsageSnapshot};
6use crate::provider::{ProviderContext, ProviderMetadata, UsageProvider};
7
8#[derive(Debug, Default, serde::Deserialize)]
11struct CursorUsageSummary {
12 #[serde(default, rename = "membershipType")]
13 membership_type: Option<String>,
14 #[serde(default, rename = "billingCycleEnd")]
15 billing_cycle_end: Option<String>,
16 #[serde(default, rename = "individualUsage")]
17 individual_usage: Option<CursorIndividualUsage>,
18 #[serde(default, rename = "teamUsage")]
19 team_usage: Option<CursorTeamUsage>,
20}
21
22#[derive(Debug, Default, serde::Deserialize)]
23struct CursorIndividualUsage {
24 #[serde(default)]
25 plan: Option<CursorPlanUsage>,
26 #[serde(default, rename = "onDemand")]
27 on_demand: Option<CursorMoneyUsage>,
28 #[serde(default)]
29 overall: Option<CursorMoneyUsage>,
30}
31
32#[derive(Debug, Default, serde::Deserialize)]
33struct CursorPlanUsage {
34 #[serde(default)]
36 used: Option<i64>,
37 #[serde(default)]
39 limit: Option<i64>,
40 #[serde(default, rename = "autoPercentUsed")]
41 auto_percent_used: Option<f64>,
42 #[serde(default, rename = "apiPercentUsed")]
43 api_percent_used: Option<f64>,
44 #[serde(default, rename = "totalPercentUsed")]
45 total_percent_used: Option<f64>,
46}
47
48#[derive(Debug, Default, serde::Deserialize)]
50struct CursorMoneyUsage {
51 #[serde(default)]
52 used: Option<i64>,
53 #[serde(default)]
54 limit: Option<i64>,
55 #[serde(default)]
57 #[allow(dead_code)]
58 remaining: Option<i64>,
59}
60
61#[derive(Debug, Default, serde::Deserialize)]
62struct CursorTeamUsage {
63 #[serde(default, rename = "onDemand")]
64 on_demand: Option<CursorMoneyUsage>,
65 #[serde(default)]
66 pooled: Option<CursorMoneyUsage>,
67}
68
69#[derive(Debug, Default, serde::Deserialize)]
72struct CursorUserInfo {
73 #[serde(default)]
74 sub: Option<String>,
75 #[serde(default)]
77 #[allow(dead_code)]
78 email: Option<String>,
79}
80
81#[derive(Debug, Default, serde::Deserialize)]
82struct CursorUsageResponse {
83 #[serde(default, rename = "gpt-4")]
84 gpt4: Option<CursorModelUsage>,
85}
86
87#[derive(Debug, Default, serde::Deserialize)]
88struct CursorModelUsage {
89 #[serde(default, rename = "numRequests")]
90 num_requests: Option<i64>,
91 #[serde(default, rename = "maxRequestUsage")]
92 max_request_usage: Option<i64>,
93}
94
95impl CursorUsageSummary {
96 fn plan_percent(&self) -> f64 {
98 let clamp = |v: f64| v.clamp(0.0, 100.0);
99 let plan = self.individual_usage.as_ref().and_then(|u| u.plan.as_ref());
100 if let Some(total) = plan.and_then(|p| p.total_percent_used) {
101 return clamp(total);
102 }
103 let auto = plan.and_then(|p| p.auto_percent_used).map(clamp);
104 let api = plan.and_then(|p| p.api_percent_used).map(clamp);
105 match (auto, api) {
106 (Some(a), Some(b)) => return clamp((a + b) / 2.0),
107 (Some(a), None) | (None, Some(a)) => return clamp(a),
108 (None, None) => {}
109 }
110 let ratio = |used: Option<i64>, limit: Option<i64>| -> Option<f64> {
112 match (used, limit) {
113 (Some(u), Some(l)) if l > 0 => Some(clamp((u as f64 / l as f64) * 100.0)),
114 _ => None,
115 }
116 };
117 if let Some(r) = plan.and_then(|p| ratio(p.used, p.limit)) {
118 return r;
119 }
120 let overall = self
121 .individual_usage
122 .as_ref()
123 .and_then(|u| u.overall.as_ref());
124 if let Some(r) = overall.and_then(|o| ratio(o.used, o.limit)) {
125 return r;
126 }
127 let pooled = self.team_usage.as_ref().and_then(|t| t.pooled.as_ref());
128 if let Some(r) = pooled.and_then(|p| ratio(p.used, p.limit)) {
129 return r;
130 }
131 0.0
132 }
133
134 fn on_demand_usd(&self) -> Option<(f64, Option<f64>)> {
136 let block = self
137 .individual_usage
138 .as_ref()
139 .and_then(|u| u.on_demand.as_ref())
140 .or_else(|| self.team_usage.as_ref().and_then(|t| t.on_demand.as_ref()))?;
141 let used = block.used? as f64 / 100.0;
142 let limit = block.limit.map(|l| l as f64 / 100.0);
143 Some((used, limit))
144 }
145}
146
147fn parse_iso(s: &Option<String>) -> Option<DateTime<Utc>> {
148 let raw = s.as_deref()?;
149 DateTime::parse_from_rfc3339(raw)
150 .ok()
151 .map(|d| d.with_timezone(&Utc))
152}
153
154pub struct CursorProvider {
156 metadata: ProviderMetadata,
157 base_url: Option<String>,
158}
159
160impl CursorProvider {
161 pub fn new() -> Self {
162 Self {
163 metadata: ProviderMetadata {
164 id: "cursor",
165 name: "Cursor",
166 description: "Cursor usage monitor (browser cookie)",
167 auth_methods: &["cookie", "token", "env"],
168 website: Some("https://cursor.com"),
169 },
170 base_url: None,
171 }
172 }
173
174 pub fn with_base_url(url: &str) -> Self {
175 let mut p = Self::new();
176 p.base_url = Some(url.to_string());
177 p
178 }
179
180 fn api_base(&self) -> &str {
181 self.base_url.as_deref().unwrap_or("https://cursor.com")
182 }
183
184 fn clean(raw: &str) -> String {
185 let mut value = raw.trim();
186 if value.len() >= 2
187 && ((value.starts_with('"') && value.ends_with('"'))
188 || (value.starts_with('\'') && value.ends_with('\'')))
189 {
190 value = &value[1..value.len() - 1];
191 }
192 value.trim().to_string()
193 }
194
195 fn resolve_cookie(ctx: &ProviderContext) -> Result<String, SpendPanelError> {
198 if let Some(cookie) = ctx.config.get("cookie") {
199 let cleaned = Self::clean(cookie);
200 if !cleaned.is_empty() {
201 return Ok(cleaned);
202 }
203 }
204 for key in ["token", "session_token", "api_key"] {
205 if let Some(value) = ctx.config.get(key) {
206 let cleaned = Self::clean(value);
207 if !cleaned.is_empty() {
208 return Ok(format!("WorkosCursorSessionToken={}", cleaned));
209 }
210 }
211 }
212 if let Ok(value) = std::env::var("CURSOR_SESSION_TOKEN") {
213 let cleaned = Self::clean(&value);
214 if !cleaned.is_empty() {
215 return Ok(format!("WorkosCursorSessionToken={}", cleaned));
216 }
217 }
218 Err(SpendPanelError::AuthFailed(
219 "cursor".into(),
220 "no session cookie found in cookie/token config or CURSOR_SESSION_TOKEN".into(),
221 ))
222 }
223
224 fn build_client(ctx: &ProviderContext) -> Result<reqwest::Client, SpendPanelError> {
225 reqwest::Client::builder()
226 .timeout(std::time::Duration::from_secs(ctx.timeout_secs))
227 .build()
228 .map_err(|e| SpendPanelError::NetworkError(e.to_string()))
229 }
230
231 async fn get_json<T: serde::de::DeserializeOwned>(
232 client: &reqwest::Client,
233 url: String,
234 cookie: &str,
235 ) -> Result<T, SpendPanelError> {
236 let resp = client
237 .get(url)
238 .header("Accept", "application/json")
239 .header("Cookie", cookie)
240 .send()
241 .await
242 .map_err(|e| SpendPanelError::NetworkError(e.to_string()))?;
243
244 let status = resp.status();
245 let body = resp
246 .text()
247 .await
248 .map_err(|e| SpendPanelError::NetworkError(e.to_string()))?;
249
250 if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
251 return Err(SpendPanelError::AuthFailed(
252 "cursor".into(),
253 format!("not logged in (HTTP {})", status.as_u16()),
254 ));
255 }
256 if !status.is_success() {
257 return Err(SpendPanelError::ProviderError(
258 "cursor".into(),
259 format!("HTTP {}: {}", status, body),
260 ));
261 }
262 serde_json::from_str(&body)
263 .map_err(|e| SpendPanelError::ParseError("cursor".into(), e.to_string()))
264 }
265
266 fn snapshot_from(
267 summary: &CursorUsageSummary,
268 user: Option<&CursorUserInfo>,
269 legacy: Option<&CursorUsageResponse>,
270 ) -> UsageSnapshot {
271 let mut snapshot = UsageSnapshot::new("cursor");
272
273 let legacy_window = legacy.and_then(|r| r.gpt4.as_ref()).and_then(|m| {
275 match (m.num_requests, m.max_request_usage) {
276 (Some(used), Some(limit)) if limit > 0 => Some(RateWindow::new(
277 used.max(0) as u64,
278 limit as u64,
279 "Requests",
280 0,
281 )),
282 _ => None,
283 }
284 });
285
286 if let Some(window) = legacy_window {
287 snapshot.primary_rate_window = Some(window);
288 } else {
289 let mut window = RateWindow::new(summary.plan_percent().round() as u64, 100, "Plan", 0);
290 window.resets_at = parse_iso(&summary.billing_cycle_end);
291 snapshot.primary_rate_window = Some(window);
292 }
293
294 if let Some((used, limit)) = summary.on_demand_usd() {
296 let balance = limit.map(|l| (l - used).max(0.0)).unwrap_or(0.0);
297 let mut credits = CreditsSnapshot::new(balance, "USD");
298 credits.used = Some(used);
299 credits.total = limit;
300 snapshot.credits = Some(credits);
301 }
302
303 if let Some(membership) = summary.membership_type.as_deref().filter(|m| !m.is_empty()) {
304 snapshot.plan = Some(PlanInfo {
305 name: format_membership(membership),
306 tier: None,
307 features: Vec::new(),
308 price: None,
309 currency: None,
310 billing_period: None,
311 });
312 }
313
314 let _ = user; snapshot
316 }
317}
318
319fn format_membership(raw: &str) -> String {
320 match raw.to_lowercase().as_str() {
321 "enterprise" => "Enterprise".into(),
322 "pro" => "Pro".into(),
323 "hobby" => "Hobby".into(),
324 "team" => "Team".into(),
325 other => {
326 let mut chars = other.chars();
327 match chars.next() {
328 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
329 None => String::new(),
330 }
331 }
332 }
333}
334
335impl Default for CursorProvider {
336 fn default() -> Self {
337 Self::new()
338 }
339}
340
341#[async_trait]
342impl UsageProvider for CursorProvider {
343 fn metadata(&self) -> &ProviderMetadata {
344 &self.metadata
345 }
346
347 fn detect_credentials(&self) -> bool {
348 std::env::var("CURSOR_SESSION_TOKEN")
349 .map(|v| !v.trim().is_empty())
350 .unwrap_or(false)
351 }
352
353 async fn fetch_usage(&self, ctx: &ProviderContext) -> Result<UsageSnapshot, SpendPanelError> {
354 let cookie = Self::resolve_cookie(ctx)?;
355 let client = Self::build_client(ctx)?;
356 let base = self.api_base().trim_end_matches('/');
357
358 let summary: CursorUsageSummary =
359 Self::get_json(&client, format!("{}/api/usage-summary", base), &cookie).await?;
360
361 let user: Option<CursorUserInfo> =
363 Self::get_json(&client, format!("{}/api/auth/me", base), &cookie)
364 .await
365 .ok();
366 let legacy = match user.as_ref().and_then(|u| u.sub.as_deref()) {
367 Some(sub) => Self::get_json::<CursorUsageResponse>(
368 &client,
369 format!("{}/api/usage?user={}", base, sub),
370 &cookie,
371 )
372 .await
373 .ok(),
374 None => None,
375 };
376
377 Ok(Self::snapshot_from(
378 &summary,
379 user.as_ref(),
380 legacy.as_ref(),
381 ))
382 }
383}
384
385#[cfg(test)]
386mod tests {
387 use super::*;
388 use pretty_assertions::assert_eq;
389 use wiremock::matchers::{method, path, query_param};
390 use wiremock::{Mock, MockServer, ResponseTemplate};
391
392 const SUMMARY: &str = r#"{
393 "membershipType": "pro",
394 "billingCycleEnd": "2026-07-12T00:00:00Z",
395 "individualUsage": {
396 "plan": {"used": 1500, "limit": 2000, "totalPercentUsed": 75.0},
397 "onDemand": {"used": 250, "limit": 1000, "remaining": 750}
398 }
399 }"#;
400
401 fn summary(body: &str) -> CursorUsageSummary {
402 serde_json::from_str(body).unwrap()
403 }
404
405 #[test]
406 fn test_metadata() {
407 let p = CursorProvider::new();
408 assert_eq!(p.metadata().id, "cursor");
409 assert!(p.metadata().auth_methods.contains(&"cookie"));
410 }
411
412 #[test]
413 fn test_resolve_cookie_token_wrapped() {
414 let mut ctx = ProviderContext::new();
415 ctx.config.insert("token".into(), "tok".into());
416 assert_eq!(
417 CursorProvider::resolve_cookie(&ctx).unwrap(),
418 "WorkosCursorSessionToken=tok"
419 );
420 }
421
422 #[test]
423 fn test_plan_percent_prefers_total() {
424 assert_eq!(summary(SUMMARY).plan_percent(), 75.0);
425 }
426
427 #[test]
428 fn test_plan_percent_avg_auto_api() {
429 let s = summary(
430 r#"{"individualUsage":{"plan":{"autoPercentUsed":40.0,"apiPercentUsed":60.0}}}"#,
431 );
432 assert_eq!(s.plan_percent(), 50.0);
433 }
434
435 #[test]
436 fn test_plan_percent_cents_ratio_fallback() {
437 let s = summary(r#"{"individualUsage":{"plan":{"used":300,"limit":1200}}}"#);
438 assert_eq!(s.plan_percent(), 25.0);
439 }
440
441 #[test]
442 fn test_plan_percent_team_pooled_fallback() {
443 let s = summary(r#"{"teamUsage":{"pooled":{"used":300,"limit":1000}}}"#);
445 assert_eq!(s.plan_percent(), 30.0);
446 }
447
448 #[test]
449 fn test_plan_percent_overall_fallback() {
450 let s = summary(r#"{"individualUsage":{"overall":{"used":7384,"limit":10000}}}"#);
451 assert!((s.plan_percent() - 73.84).abs() < 1e-6);
452 }
453
454 #[test]
455 fn test_on_demand_from_team_usage() {
456 let s = summary(r#"{"teamUsage":{"onDemand":{"used":150,"limit":500}}}"#);
457 let (used, limit) = s.on_demand_usd().unwrap();
458 assert_eq!(used, 1.5);
459 assert_eq!(limit, Some(5.0));
460 }
461
462 #[test]
463 fn test_on_demand_usd() {
464 let (used, limit) = summary(SUMMARY).on_demand_usd().unwrap();
465 assert_eq!(used, 2.5);
466 assert_eq!(limit, Some(10.0));
467 }
468
469 #[test]
470 fn test_snapshot_plan_window_and_credits() {
471 let snapshot = CursorProvider::snapshot_from(&summary(SUMMARY), None, None);
472 let primary = snapshot.primary_rate_window.unwrap();
473 assert_eq!(primary.used, Some(75));
474 assert_eq!(primary.limit, Some(100));
475 let credits = snapshot.credits.unwrap();
476 assert_eq!(credits.used, Some(2.5));
477 assert_eq!(credits.balance, 7.5);
478 assert_eq!(snapshot.plan.unwrap().name, "Pro");
479 }
480
481 #[test]
482 fn test_legacy_requests_override() {
483 let legacy: CursorUsageResponse =
484 serde_json::from_str(r#"{"gpt-4":{"numRequests":120,"maxRequestUsage":500}}"#).unwrap();
485 let snapshot = CursorProvider::snapshot_from(&summary(SUMMARY), None, Some(&legacy));
486 let primary = snapshot.primary_rate_window.unwrap();
487 assert_eq!(primary.used, Some(120));
488 assert_eq!(primary.limit, Some(500));
489 assert_eq!(primary.label, "Requests");
490 }
491
492 #[tokio::test]
493 async fn test_fetch_usage_success() {
494 let server = MockServer::start().await;
495 Mock::given(method("GET"))
496 .and(path("/api/usage-summary"))
497 .respond_with(ResponseTemplate::new(200).set_body_raw(SUMMARY, "application/json"))
498 .mount(&server)
499 .await;
500 Mock::given(method("GET"))
501 .and(path("/api/auth/me"))
502 .respond_with(
503 ResponseTemplate::new(200)
504 .set_body_raw(r#"{"sub":"user_1","email":"a@b.c"}"#, "application/json"),
505 )
506 .mount(&server)
507 .await;
508 Mock::given(method("GET"))
509 .and(path("/api/usage"))
510 .and(query_param("user", "user_1"))
511 .respond_with(ResponseTemplate::new(200).set_body_raw("{}", "application/json"))
512 .mount(&server)
513 .await;
514
515 let provider = CursorProvider::with_base_url(&server.uri());
516 let mut ctx = ProviderContext::new();
517 ctx.config.insert("token".into(), "tok".into());
518 let snapshot = provider.fetch_usage(&ctx).await.unwrap();
519 assert_eq!(snapshot.primary_rate_window.unwrap().used, Some(75));
520 }
521
522 #[tokio::test]
523 async fn test_fetch_usage_401_is_auth_failed() {
524 let server = MockServer::start().await;
525 Mock::given(method("GET"))
526 .and(path("/api/usage-summary"))
527 .respond_with(ResponseTemplate::new(403))
528 .mount(&server)
529 .await;
530
531 let provider = CursorProvider::with_base_url(&server.uri());
532 let mut ctx = ProviderContext::new();
533 ctx.config.insert("token".into(), "bad".into());
534 let err = provider.fetch_usage(&ctx).await.unwrap_err();
535 assert!(matches!(err, SpendPanelError::AuthFailed(_, _)));
536 }
537}