1use crate::models::{CursorQuotaSnapshot, CursorUsageSummary, QuotaSource, QuotaWindow};
16use crate::quota::http::{detect_cli_version, iso_to_unix_secs};
17use crate::quota::provider::QuotaOutcome;
18use crate::utils::get_cursor_auth_path;
19use anyhow::{Context, Result};
20use base64::Engine;
21use reqwest::blocking::Client;
22use std::path::Path;
23use std::sync::OnceLock;
24
25const CURSOR_USAGE_URL: &str = "https://cursor.com/api/usage-summary";
27const CURSOR_FALLBACK_VERSION: &str = "2026.07.07";
30pub const CURSOR_LOGIN_HINT: &str = "run: cursor-agent login";
33
34pub(crate) fn cursor_ua() -> &'static str {
40 static UA: OnceLock<String> = OnceLock::new();
41 UA.get_or_init(|| {
42 format!(
43 "cursor-agent/{}",
44 detect_cli_version(
45 "cursor-agent",
46 "cursor_version.json",
47 CURSOR_FALLBACK_VERSION
48 )
49 )
50 })
51 .as_str()
52}
53
54pub(crate) struct CursorSession {
60 pub(crate) cookie: String,
61 pub(crate) exp: i64,
62}
63
64fn decode_jwt_payload(token: &str) -> Option<serde_json::Value> {
66 let payload = token.split('.').nth(1)?;
67 let trimmed = payload.trim_end_matches('=');
69 let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
70 .decode(trimmed)
71 .ok()?;
72 serde_json::from_slice(&bytes).ok()
73}
74
75pub(crate) fn read_cursor_session(body: &str) -> Option<CursorSession> {
80 let root: serde_json::Value = serde_json::from_str(body).ok()?;
81 let access = root.get("accessToken")?.as_str()?;
82 if access.is_empty() {
83 return None;
84 }
85 let claims = decode_jwt_payload(access)?;
86 let sub = claims.get("sub")?.as_str()?;
87 let uid = sub.rsplit('|').next().unwrap_or(sub);
88 if uid.is_empty() {
89 return None;
90 }
91 let exp = claims.get("exp").and_then(|v| v.as_i64()).unwrap_or(0);
92 Some(CursorSession {
93 cookie: format!("WorkosCursorSessionToken={uid}%3A%3A{access}"),
94 exp,
95 })
96}
97
98pub fn map_cursor_usage(body: &str, now: i64) -> Result<CursorQuotaSnapshot> {
104 let resp: CursorUsageSummary =
105 serde_json::from_str(body).context("Failed to parse Cursor usage summary")?;
106
107 let reset = resp.billing_cycle_end.as_deref().and_then(iso_to_unix_secs);
108 let win = |pct: Option<f64>| {
114 pct.map(|p| QuotaWindow {
115 used_percent: p.clamp(0.0, 100.0),
116 resets_at_unix: reset,
117 })
118 };
119
120 let plan = resp.individual_usage.as_ref().and_then(|u| u.plan.as_ref());
121 let total = win(plan.and_then(|p| p.total_percent_used));
122 let auto = win(plan.and_then(|p| p.auto_percent_used));
123 let api = win(plan.and_then(|p| p.api_percent_used));
124
125 let individual_od = resp
128 .individual_usage
129 .as_ref()
130 .and_then(|u| u.on_demand.as_ref())
131 .filter(|d| d.enabled == Some(true))
132 .and_then(|d| d.used);
133 let team_od = resp
134 .team_usage
135 .as_ref()
136 .and_then(|t| t.on_demand.as_ref())
137 .and_then(|d| d.used);
138 let on_demand_dollars = individual_od.or(team_od).map(|cents| cents / 100.0);
139
140 let is_unlimited = resp.is_unlimited.unwrap_or(false);
141 let limit_reached = !is_unlimited
142 && total
143 .as_ref()
144 .map(|w| w.used_percent >= 100.0)
145 .unwrap_or(false);
146
147 Ok(CursorQuotaSnapshot {
148 source: QuotaSource::Api,
149 fetched_at: now,
150 plan_type: resp.membership_type.filter(|s| !s.is_empty()),
151 total,
152 auto,
153 api,
154 on_demand_dollars,
155 limit_reached,
156 needs_login: false,
157 })
158}
159
160enum FetchResult {
162 Ok(CursorQuotaSnapshot),
163 Unauthorized,
165 Transient,
167}
168
169fn fetch_cursor_usage(client: &Client, cookie: &str, now: i64, usage_url: &str) -> FetchResult {
171 let resp = match client
172 .get(usage_url)
173 .header(reqwest::header::COOKIE, cookie)
174 .header(reqwest::header::ACCEPT, "application/json")
175 .header(reqwest::header::USER_AGENT, cursor_ua())
176 .send()
177 {
178 Ok(r) => r,
179 Err(e) => {
180 log::warn!("cursor quota fetch: request failed: {e}");
181 return FetchResult::Transient;
182 }
183 };
184 let status = resp.status();
185 if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
186 return FetchResult::Unauthorized;
187 }
188 if !status.is_success() {
189 log::warn!("cursor quota fetch: HTTP {status}");
190 return FetchResult::Transient;
191 }
192 match resp.text() {
193 Ok(text) => match map_cursor_usage(&text, now) {
194 Ok(snap) => FetchResult::Ok(snap),
195 Err(e) => {
196 log::warn!("cursor quota fetch: failed to parse usage response: {e}");
197 FetchResult::Transient
198 }
199 },
200 Err(e) => {
201 log::warn!("cursor quota fetch: failed to read response body: {e}");
202 FetchResult::Transient
203 }
204 }
205}
206
207pub fn fetch_cursor_raw(client: &Client) -> Result<(u16, String)> {
219 fetch_cursor_raw_from(client, CURSOR_USAGE_URL, &get_cursor_auth_path()?)
220}
221
222pub(crate) fn fetch_cursor_raw_from(
227 client: &Client,
228 usage_url: &str,
229 auth_path: &Path,
230) -> Result<(u16, String)> {
231 let path = auth_path;
232 let body = std::fs::read_to_string(path).with_context(|| {
233 format!(
234 "no Cursor credentials at {} ({CURSOR_LOGIN_HINT})",
235 path.display()
236 )
237 })?;
238 let session = read_cursor_session(&body).with_context(|| {
239 format!(
240 "no usable Cursor session in {} ({CURSOR_LOGIN_HINT})",
241 path.display()
242 )
243 })?;
244 let resp = client
245 .get(usage_url)
246 .header(reqwest::header::COOKIE, session.cookie.as_str())
247 .header(reqwest::header::ACCEPT, "application/json")
248 .header(reqwest::header::USER_AGENT, cursor_ua())
249 .send()
250 .context("Failed to send Cursor usage request")?;
251 let status = resp.status().as_u16();
252 let text = resp
253 .text()
254 .context("Failed to read Cursor usage response body")?;
255 Ok((status, text))
256}
257
258#[derive(Default)]
261pub struct CursorState;
262
263impl CursorState {
264 pub fn resolve(&mut self, client: &Client) -> QuotaOutcome<CursorQuotaSnapshot> {
266 let now = chrono::Local::now().timestamp();
267 let path = match get_cursor_auth_path() {
268 Ok(p) => p,
269 Err(_) => return QuotaOutcome::Transient,
270 };
271 let body = match std::fs::read_to_string(&path) {
272 Ok(b) => b,
273 Err(_) => return QuotaOutcome::Transient,
274 };
275 let session = match read_cursor_session(&body) {
279 Some(s) => s,
280 None => return QuotaOutcome::NeedsLogin,
281 };
282 if session.exp > 0 && session.exp <= now {
284 return QuotaOutcome::NeedsLogin;
285 }
286 match fetch_cursor_usage(client, &session.cookie, now, CURSOR_USAGE_URL) {
287 FetchResult::Ok(snap) => QuotaOutcome::Data(snap),
288 FetchResult::Unauthorized => QuotaOutcome::NeedsLogin,
289 FetchResult::Transient => QuotaOutcome::Transient,
290 }
291 }
292}
293
294#[cfg(test)]
295mod tests {
296 use super::*;
297 use base64::Engine;
298
299 fn fake_jwt(payload: &serde_json::Value) -> String {
301 let enc = |b: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b);
302 format!(
303 "{}.{}.{}",
304 enc(b"{\"alg\":\"none\"}"),
305 enc(payload.to_string().as_bytes()),
306 "sig"
307 )
308 }
309
310 #[test]
311 fn builds_cookie_from_jwt_sub() {
312 let jwt = fake_jwt(&serde_json::json!({
313 "sub": "github|user_01ABC",
314 "exp": 9_999_999_999i64,
315 }));
316 let body = format!(r#"{{ "accessToken": "{jwt}", "refreshToken": "rt" }}"#);
317 let session = read_cursor_session(&body).unwrap();
318 assert_eq!(
319 session.cookie,
320 format!("WorkosCursorSessionToken=user_01ABC%3A%3A{jwt}")
321 );
322 assert_eq!(session.exp, 9_999_999_999);
323 }
324
325 #[test]
326 fn sub_without_pipe_uses_whole_value() {
327 let jwt = fake_jwt(&serde_json::json!({ "sub": "user_only", "exp": 1 }));
328 let body = format!(r#"{{ "accessToken": "{jwt}" }}"#);
329 let session = read_cursor_session(&body).unwrap();
330 assert!(session.cookie.contains("=user_only%3A%3A"));
331 }
332
333 #[test]
334 fn missing_access_token_is_none() {
335 assert!(read_cursor_session(r#"{ "refreshToken": "rt" }"#).is_none());
336 }
337
338 const SUMMARY: &str = r#"{
339 "billingCycleStart": "2026-06-23T17:36:23.480Z",
340 "billingCycleEnd": "2026-07-23T17:36:23.480Z",
341 "membershipType": "free",
342 "isUnlimited": false,
343 "individualUsage": {
344 "plan": { "autoPercentUsed": 100, "apiPercentUsed": 44, "totalPercentUsed": 94 },
345 "onDemand": { "enabled": false, "used": 0, "limit": null }
346 }
347 }"#;
348
349 #[test]
350 fn maps_cursor_usage() {
351 let snap = map_cursor_usage(SUMMARY, 1_000_000).unwrap();
352 assert_eq!(snap.source, QuotaSource::Api);
353 assert_eq!(snap.plan_type.as_deref(), Some("free"));
354 assert_eq!(snap.total.as_ref().unwrap().used_percent, 94.0);
356 assert_eq!(snap.auto.as_ref().unwrap().used_percent, 100.0);
357 assert_eq!(snap.api.as_ref().unwrap().used_percent, 44.0);
358 assert!(snap.total.as_ref().unwrap().resets_at_unix.unwrap() > 0);
359 assert!(snap.on_demand_dollars.is_none());
361 assert!(!snap.limit_reached);
362 }
363
364 #[test]
365 fn on_demand_dollars_from_cents_when_enabled() {
366 let body = r#"{ "membershipType": "pro", "individualUsage": { "onDemand": { "enabled": true, "used": 1840 } } }"#;
367 let snap = map_cursor_usage(body, 1).unwrap();
368 assert_eq!(snap.on_demand_dollars, Some(18.40));
369 }
370
371 #[test]
372 fn on_demand_falls_back_to_team_usage() {
373 let body = r#"{
375 "membershipType": "enterprise",
376 "individualUsage": { "onDemand": { "enabled": false } },
377 "teamUsage": { "onDemand": { "used": 5000 } }
378 }"#;
379 let snap = map_cursor_usage(body, 1).unwrap();
380 assert_eq!(snap.on_demand_dollars, Some(50.0));
381 }
382
383 #[test]
384 fn flags_limit_when_total_maxed() {
385 let body = r#"{ "isUnlimited": false, "individualUsage": { "plan": { "totalPercentUsed": 100 } } }"#;
386 let snap = map_cursor_usage(body, 1).unwrap();
387 assert!(snap.limit_reached);
388 }
389
390 #[test]
391 fn unlimited_never_flags_limit() {
392 let body = r#"{ "isUnlimited": true, "individualUsage": { "plan": { "totalPercentUsed": 100 } } }"#;
394 let snap = map_cursor_usage(body, 1).unwrap();
395 assert!(!snap.limit_reached);
396 }
397
398 #[test]
399 fn idle_account_reads_as_unused() {
400 let body = r#"{
403 "membershipType": "free",
404 "isUnlimited": false,
405 "individualUsage": {
406 "plan": {
407 "used": 0, "limit": 0, "remaining": 0,
408 "autoPercentUsed": 0, "apiPercentUsed": 0, "totalPercentUsed": 0
409 }
410 }
411 }"#;
412 let snap = map_cursor_usage(body, 1).unwrap();
413 assert_eq!(snap.total.as_ref().unwrap().used_percent, 0.0);
414 assert_eq!(snap.auto.as_ref().unwrap().used_percent, 0.0);
415 assert_eq!(snap.api.as_ref().unwrap().used_percent, 0.0);
416 assert!(!snap.limit_reached);
417 }
418
419 #[test]
420 fn out_of_range_percentages_are_clamped() {
421 let body = r#"{ "individualUsage": { "plan": { "totalPercentUsed": 150, "autoPercentUsed": -5 } } }"#;
424 let snap = map_cursor_usage(body, 1).unwrap();
425 assert_eq!(snap.total.as_ref().unwrap().used_percent, 100.0);
426 assert_eq!(snap.auto.as_ref().unwrap().used_percent, 0.0);
427 assert!(snap.limit_reached);
428 }
429
430 #[test]
431 fn missing_usage_is_tolerated() {
432 let snap = map_cursor_usage(r#"{ "membershipType": "free" }"#, 1).unwrap();
433 assert!(snap.total.is_none());
434 assert!(snap.auto.is_none());
435 assert!(snap.api.is_none());
436 assert!(!snap.limit_reached);
437 }
438
439 #[test]
442 fn fetch_cursor_usage_maps_200_and_401() {
443 use crate::quota::http::build_client;
444 use httpmock::prelude::*;
445
446 let server = MockServer::start();
447 let ok = server.mock(|when, then| {
448 when.method(GET).path("/ok");
449 then.status(200).body(SUMMARY);
450 });
451 server.mock(|when, then| {
452 when.method(GET).path("/forbidden");
453 then.status(403);
454 });
455 let client = build_client().unwrap();
456
457 match fetch_cursor_usage(&client, "cookie", 1_000_000, &server.url("/ok")) {
458 FetchResult::Ok(snap) => {
459 assert_eq!(snap.plan_type.as_deref(), Some("free"));
460 assert_eq!(snap.total.as_ref().unwrap().used_percent, 94.0);
461 }
462 _ => panic!("expected Ok"),
463 }
464 ok.assert();
465 assert!(matches!(
466 fetch_cursor_usage(&client, "cookie", 0, &server.url("/forbidden")),
467 FetchResult::Unauthorized
468 ));
469 }
470
471 #[test]
472 fn fetch_cursor_raw_from_reads_session_and_returns_body() {
473 use crate::quota::http::build_client;
474 use httpmock::prelude::*;
475
476 let server = MockServer::start();
477 server.mock(|when, then| {
478 when.method(GET).path("/usage");
479 then.status(200).body(SUMMARY);
480 });
481 let jwt = fake_jwt(&serde_json::json!({ "sub": "github|u1", "exp": 9_999_999_999i64 }));
482 let dir = tempfile::tempdir().unwrap();
483 let auth = dir.path().join("auth.json");
484 std::fs::write(&auth, format!(r#"{{ "accessToken": "{jwt}" }}"#)).unwrap();
485
486 let client = build_client().unwrap();
487 let (status, body) =
488 fetch_cursor_raw_from(&client, &server.url("/usage"), &auth).expect("raw fetch");
489 assert_eq!(status, 200);
490 assert!(body.contains("membershipType"));
491 }
492}