Skip to main content

usage_monitor_cli/provider/
cursor.rs

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// MARK: - usage-summary response (modern token-based plans)
9
10#[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    /// Usage in cents.
35    #[serde(default)]
36    used: Option<i64>,
37    /// Limit in cents.
38    #[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/// Cents-based usage block shared by on-demand / overall / pooled.
49#[derive(Debug, Default, serde::Deserialize)]
50struct CursorMoneyUsage {
51    #[serde(default)]
52    used: Option<i64>,
53    #[serde(default)]
54    limit: Option<i64>,
55    /// Remaining cents — accepted from the API but not currently surfaced.
56    #[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// MARK: - /api/auth/me + legacy /api/usage
70
71#[derive(Debug, Default, serde::Deserialize)]
72struct CursorUserInfo {
73    #[serde(default)]
74    sub: Option<String>,
75    /// Account email — parsed for future identity surfacing.
76    #[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    /// Headline plan percent, mirroring CodexBar's precedence.
97    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        // Fall through to cents ratios: plan → overall → pooled.
111        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    /// On-demand spend (used, limit) in USD, when present.
135    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
154/// Cursor usage provider (browser-cookie auth).
155pub 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    /// Builds the `Cookie` header. A full `cookie` value is sent verbatim; a bare
196    /// session `token` is wrapped in Cursor's WorkOS session cookie name.
197    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        // Legacy request-based plan takes the headline window when present.
274        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        // On-demand spend surfaces as a credits/spend pool in USD.
295        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; // email reserved for future identity surfacing
315        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        // Identity + legacy request quota are best-effort; not all plans expose them.
362        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        // No individual usage → fall through to the shared team pool ratio.
444        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}