diff --git a/rust/src/providers/claude/oauth/mod.rs b/rust/src/providers/claude/oauth/mod.rs index b774c6c64b..54a62cb151 100644 --- a/rust/src/providers/claude/oauth/mod.rs +++ b/rust/src/providers/claude/oauth/mod.rs @@ -583,6 +583,70 @@ mod tests { ); } + #[test] + fn issue_210_reporter_shape_secondary_is_one_percent_not_one_hundred() { + // Mirrors the reporter JSON: session 8%, fable 2%, all-models should be 1% + // while seven_day.utilization is the stale 1.0 (would display as 100%). + let response: OAuthUsageResponse = serde_json::from_str( + r#"{ + "five_hour": { + "utilization": 8.0, + "resets_at": "2026-07-20T04:29:59.671218Z" + }, + "seven_day": { + "utilization": 1.0, + "resets_at": "2026-07-26T22:59:59.671246Z" + }, + "limits": [ + { + "kind": "weekly_all", + "group": "weekly", + "percent": 1.0, + "resets_at": "2026-07-26T22:59:59.671595Z" + }, + { + "kind": "weekly_scoped", + "group": "weekly", + "percent": 2.0, + "resets_at": "2026-07-26T22:59:59.671595Z", + "scope": { + "model": { + "id": "claude-fable", + "display_name": "Fable" + } + } + } + ] + }"#, + ) + .expect("issue 210 body"); + + let credentials = ClaudeOAuthCredentials { + access_token: "token".to_string(), + refresh_token: None, + expires_at: None, + scopes: vec![], + rate_limit_tier: Some("default_claude_max_5x".to_string()), + }; + let usage = ClaudeOAuthFetcher::new().build_usage_snapshot(&response, &credentials); + + assert_eq!(usage.login_method.as_deref(), Some("Claude Max 5x")); + assert!((usage.primary.used_percent - 8.0).abs() < f64::EPSILON); + let weekly = usage.secondary.expect("secondary weekly"); + assert!( + (weekly.used_percent - 1.0).abs() < f64::EPSILON, + "secondary was {}, expected 1% (not 100%)", + weekly.used_percent + ); + assert!((weekly.used_percent - 100.0).abs() > 1.0); + let fable = usage + .extra_rate_windows + .iter() + .find(|w| w.title.contains("Fable")) + .expect("Fable only window"); + assert!((fable.window.used_percent - 2.0).abs() < f64::EPSILON); + } + #[test] fn parses_retry_after_seconds() { let header = HeaderValue::from_static("17"); diff --git a/rust/src/providers/cursor/mod.rs b/rust/src/providers/cursor/mod.rs index 2bb43643fd..4f5e03da79 100755 --- a/rust/src/providers/cursor/mod.rs +++ b/rust/src/providers/cursor/mod.rs @@ -43,10 +43,13 @@ impl CursorProvider { ctx: &FetchContext, ) -> Result { match ctx.source_mode { - SourceMode::Auto | SourceMode::Web => self.fetch_web_usage_parts(ctx).await, - SourceMode::Cli | SourceMode::OAuth => { - Err(ProviderError::UnsupportedSource(ctx.source_mode)) + // Cli is only ever set by the shell for "no cookie yet"; treat it as + // web so empty-manual users get browser cookie attempt (or AuthRequired) + // instead of "Source mode 'Cli' not supported" (#212). + SourceMode::Auto | SourceMode::Web | SourceMode::Cli => { + self.fetch_web_usage_parts(ctx).await } + SourceMode::OAuth => Err(ProviderError::UnsupportedSource(ctx.source_mode)), } } @@ -138,3 +141,46 @@ impl Provider for CursorProvider { true } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::FetchContext; + + #[tokio::test] + async fn cli_mode_does_not_return_unsupported_source() { + let provider = CursorProvider::new(); + let ctx = FetchContext { + source_mode: SourceMode::Cli, + manual_cookie_header: None, + ..FetchContext::default() + }; + let err = provider + .fetch_usage(&ctx) + .await + .expect_err("no cookies on this machine"); + // Must not be UnsupportedSource — that was the user-visible #212 bug. + assert!( + !matches!(err, ProviderError::UnsupportedSource(_)), + "unexpected UnsupportedSource: {err}" + ); + assert!( + matches!( + err, + ProviderError::NoCookies | ProviderError::AuthRequired | ProviderError::Other(_) + ), + "expected cookie/auth style error, got: {err}" + ); + } + + #[tokio::test] + async fn oauth_mode_still_unsupported() { + let provider = CursorProvider::new(); + let ctx = FetchContext { + source_mode: SourceMode::OAuth, + ..FetchContext::default() + }; + let err = provider.fetch_usage(&ctx).await.expect_err("oauth unsupported"); + assert!(matches!(err, ProviderError::UnsupportedSource(SourceMode::OAuth))); + } +} diff --git a/rust/src/providers/opencode/mod.rs b/rust/src/providers/opencode/mod.rs index a2de72a17f..d7c3fd861c 100755 --- a/rust/src/providers/opencode/mod.rs +++ b/rust/src/providers/opencode/mod.rs @@ -719,4 +719,50 @@ mod tests { // 0.25 fraction scales to 25% assert!((snap.secondary.as_ref().unwrap().used_percent - 25.0).abs() < f64::EPSILON); } + + #[test] + fn parse_subscription_accepts_realistic_server_fn_body() { + let provider = OpenCodeProvider::new(); + // Shape closer to a live server-fn payload: nested under result/data. + let body = r#"{ + "result": { + "data": { + "rollingUsage": { "usagePercent": 33.0, "resetInSec": 1800 }, + "weeklyUsage": { "usagePercent": 12.5, "resetInSec": 604800 }, + "renewAt": "2026-08-01T00:00:00Z" + } + } + }"#; + let snap = provider + .parse_subscription(body) + .expect("server-fn body should parse end-to-end"); + assert!((snap.primary.used_percent - 33.0).abs() < f64::EPSILON); + assert!((snap.secondary.as_ref().unwrap().used_percent - 12.5).abs() < f64::EPSILON); + assert_eq!(snap.login_method.as_deref(), Some("OpenCode")); + } + + #[tokio::test] + async fn web_mode_without_cookies_is_auth_not_unsupported() { + let provider = OpenCodeProvider::new(); + let ctx = FetchContext { + source_mode: SourceMode::Web, + manual_cookie_header: None, + ..FetchContext::default() + }; + let err = provider + .fetch_usage(&ctx) + .await + .expect_err("no cookies available"); + assert!( + !matches!(err, ProviderError::UnsupportedSource(_)), + "got UnsupportedSource: {err}" + ); + assert!( + matches!( + err, + ProviderError::AuthRequired | ProviderError::NoCookies | ProviderError::Other(_) + ), + "got: {err}" + ); + } }