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