diff --git a/Sources/App/ClaudeBarApp.swift b/Sources/App/ClaudeBarApp.swift index 8d1e4a75..904c33fd 100644 --- a/Sources/App/ClaudeBarApp.swift +++ b/Sources/App/ClaudeBarApp.swift @@ -85,6 +85,7 @@ struct ClaudeBarApp: App { settingsRepository: settingsRepository ), KiroProvider(probe: KiroUsageProbe(), settingsRepository: settingsRepository), + CursorProvider(probe: CursorUsageProbe(), settingsRepository: settingsRepository), MiniMaxProvider( probe: MiniMaxUsageProbe(settingsRepository: settingsRepository), settingsRepository: settingsRepository diff --git a/Sources/App/Resources/Assets.xcassets/CursorIcon.imageset/Contents.json b/Sources/App/Resources/Assets.xcassets/CursorIcon.imageset/Contents.json new file mode 100644 index 00000000..d53c7bf3 --- /dev/null +++ b/Sources/App/Resources/Assets.xcassets/CursorIcon.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "CursorIcon.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Sources/App/Resources/Assets.xcassets/CursorIcon.imageset/CursorIcon.svg b/Sources/App/Resources/Assets.xcassets/CursorIcon.imageset/CursorIcon.svg new file mode 100644 index 00000000..2eb7c8bb --- /dev/null +++ b/Sources/App/Resources/Assets.xcassets/CursorIcon.imageset/CursorIcon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Sources/App/Resources/CursorIcon.svg b/Sources/App/Resources/CursorIcon.svg new file mode 100644 index 00000000..2eb7c8bb --- /dev/null +++ b/Sources/App/Resources/CursorIcon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Sources/App/Views/ProviderVisualIdentity.swift b/Sources/App/Views/ProviderVisualIdentity.swift index cb31499a..7fe942b4 100644 --- a/Sources/App/Views/ProviderVisualIdentity.swift +++ b/Sources/App/Views/ProviderVisualIdentity.swift @@ -296,6 +296,34 @@ extension KiroProvider: ProviderVisualIdentity { } } +// MARK: - CursorProvider Visual Identity + +extension CursorProvider: ProviderVisualIdentity { + public var symbolIcon: String { "cursorarrow.rays" } + + public var iconAssetName: String { "CursorIcon" } + + public func themeColor(for scheme: ColorScheme) -> Color { + // Cursor brand teal/cyan + scheme == .dark + ? Color(red: 0.20, green: 0.78, blue: 0.82) + : Color(red: 0.12, green: 0.62, blue: 0.66) + } + + public func themeGradient(for scheme: ColorScheme) -> LinearGradient { + LinearGradient( + colors: [ + themeColor(for: scheme), + scheme == .dark + ? Color(red: 0.15, green: 0.55, blue: 0.75) + : Color(red: 0.08, green: 0.45, blue: 0.60) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } +} + // MARK: - MiniMaxProvider Visual Identity extension MiniMaxProvider: ProviderVisualIdentity { @@ -412,6 +440,10 @@ enum ProviderVisualIdentityLookup { return scheme == .dark ? Color(red: 0.91, green: 0.27, blue: 0.42) : Color(red: 0.82, green: 0.20, blue: 0.35) + case "cursor": + return scheme == .dark + ? Color(red: 0.20, green: 0.78, blue: 0.82) + : Color(red: 0.12, green: 0.62, blue: 0.66) default: return BaseTheme.purpleVibrant } @@ -467,6 +499,10 @@ enum ProviderVisualIdentityLookup { secondaryColor = scheme == .dark ? Color(red: 0.96, green: 0.53, blue: 0.24) : Color(red: 0.86, green: 0.43, blue: 0.14) + case "cursor": + secondaryColor = scheme == .dark + ? Color(red: 0.15, green: 0.55, blue: 0.75) + : Color(red: 0.08, green: 0.45, blue: 0.60) default: return LinearGradient( colors: [BaseTheme.coralAccent, BaseTheme.pinkHot], @@ -496,6 +532,7 @@ enum ProviderVisualIdentityLookup { case "kimi": return "KimiIcon" case "kiro": return "KiroIcon" case "minimax": return "MiniMaxIcon" + case "cursor": return "CursorIcon" default: return "QuestionIcon" } } @@ -514,6 +551,7 @@ enum ProviderVisualIdentityLookup { case "kimi": return "Kimi" case "kiro": return "Kiro" case "minimax": return "MiniMax" + case "cursor": return "Cursor" default: return providerId.capitalized } } @@ -532,6 +570,7 @@ enum ProviderVisualIdentityLookup { case "kimi": return "k.square.fill" case "kiro": return "wand.and.stars.inverse" case "minimax": return "waveform" + case "cursor": return "cursorarrow.rays" default: return "questionmark.circle.fill" } } diff --git a/Sources/Domain/Provider/Cursor/CursorProvider.swift b/Sources/Domain/Provider/Cursor/CursorProvider.swift new file mode 100644 index 00000000..2c4e77a0 --- /dev/null +++ b/Sources/Domain/Provider/Cursor/CursorProvider.swift @@ -0,0 +1,83 @@ +import Foundation +import Observation + +/// Cursor AI provider - a rich domain model. +/// Observable class with its own state (isSyncing, snapshot, error). +@Observable +public final class CursorProvider: AIProvider, @unchecked Sendable { + // MARK: - Identity (Protocol Requirement) + + public let id: String = "cursor" + public let name: String = "Cursor" + public let cliCommand: String = "cursor" + + public var dashboardURL: URL? { + URL(string: "https://www.cursor.com/settings") + } + + public var statusPageURL: URL? { + nil + } + + /// Whether the provider is enabled (persisted via settingsRepository) + public var isEnabled: Bool { + didSet { + settingsRepository.setEnabled(isEnabled, forProvider: id) + } + } + + // MARK: - State (Observable) + + /// Whether the provider is currently syncing data + public private(set) var isSyncing: Bool = false + + /// The current usage snapshot (nil if never refreshed or unavailable) + public private(set) var snapshot: UsageSnapshot? + + /// The last error that occurred during refresh + public private(set) var lastError: Error? + + // MARK: - Internal + + /// The probe for fetching usage data via Cursor's API + private let probe: any UsageProbe + + /// The settings repository for persisting provider settings + private let settingsRepository: any ProviderSettingsRepository + + // MARK: - Initialization + + /// Creates a Cursor provider + /// - Parameters: + /// - probe: The probe to use for fetching usage data + /// - settingsRepository: The repository for persisting settings + public init(probe: any UsageProbe, settingsRepository: any ProviderSettingsRepository) { + self.probe = probe + self.settingsRepository = settingsRepository + self.isEnabled = settingsRepository.isEnabled(forProvider: "cursor") + } + + // MARK: - AIProvider Protocol + + public func isAvailable() async -> Bool { + await probe.isAvailable() + } + + /// Refreshes the usage data and updates the snapshot. + /// Sets isSyncing during refresh and captures any errors. + @discardableResult + public func refresh() async throws -> UsageSnapshot { + isSyncing = true + defer { isSyncing = false } + + do { + let newSnapshot = try await probe.probe() + snapshot = newSnapshot + lastError = nil + return newSnapshot + } catch { + lastError = error + throw error + } + } +} diff --git a/Sources/Infrastructure/Cursor/CursorUsageProbe.swift b/Sources/Infrastructure/Cursor/CursorUsageProbe.swift new file mode 100644 index 00000000..af12f303 --- /dev/null +++ b/Sources/Infrastructure/Cursor/CursorUsageProbe.swift @@ -0,0 +1,315 @@ +import Foundation +import Domain + +/// Infrastructure adapter that probes Cursor's usage API to fetch quota data. +/// +/// Cursor stores its authentication in a local SQLite database. This probe: +/// 1. Reads the access token from `state.vscdb` +/// 2. Decodes the JWT to extract the user ID +/// 3. Calls `https://cursor.com/api/usage-summary` with cookie auth +/// 4. Parses the response into quota percentages +/// +/// The auth cookie format is: `WorkosCursorSessionToken={userId}::{accessToken}` +/// +/// Actual API response shape (usage-summary): +/// ```json +/// { +/// "membershipType": "ultra", +/// "isUnlimited": false, +/// "billingCycleStart": "2026-02-06T03:34:49.000Z", +/// "billingCycleEnd": "2026-03-06T03:34:49.000Z", +/// "individualUsage": { +/// "plan": { +/// "enabled": true, +/// "used": 326, +/// "limit": 40000, +/// "remaining": 39674 +/// }, +/// "onDemand": { +/// "enabled": false, +/// "used": 0, +/// "limit": null, +/// "remaining": null +/// } +/// } +/// } +/// ``` +public struct CursorUsageProbe: UsageProbe { + private let networkClient: any NetworkClient + private let timeout: TimeInterval + private let dbPathOverride: String? + + private static let usageSummaryURL = "https://cursor.com/api/usage-summary" + + /// The default path to Cursor's SQLite database on macOS + static let defaultDatabasePath: String = { + let home = FileManager.default.homeDirectoryForCurrentUser.path + return "\(home)/Library/Application Support/Cursor/User/globalStorage/state.vscdb" + }() + + public init( + networkClient: any NetworkClient = URLSession.shared, + timeout: TimeInterval = 15.0, + dbPathOverride: String? = nil + ) { + self.networkClient = networkClient + self.timeout = timeout + self.dbPathOverride = dbPathOverride + } + + // MARK: - UsageProbe + + public func isAvailable() async -> Bool { + let dbPath = dbPathOverride ?? Self.defaultDatabasePath + let dbExists = FileManager.default.fileExists(atPath: dbPath) + if !dbExists { + AppLog.probes.debug("Cursor: Database not found at \(dbPath)") + } + return dbExists + } + + public func probe() async throws -> UsageSnapshot { + let dbPath = dbPathOverride ?? Self.defaultDatabasePath + + guard FileManager.default.fileExists(atPath: dbPath) else { + AppLog.probes.error("Cursor: Database not found at \(dbPath)") + throw ProbeError.cliNotFound("Cursor (database not found)") + } + + AppLog.probes.info("Cursor: Reading auth token from database...") + + let accessToken = try readAccessToken(from: dbPath) + let userId = try Self.extractUserIdFromJWT(accessToken) + let cookie = "WorkosCursorSessionToken=\(userId)::\(accessToken)" + + AppLog.probes.info("Cursor: Fetching usage summary...") + + let response = try await fetchUsageSummary(cookie: cookie) + let snapshot = try Self.parseUsageSummary(response) + + AppLog.probes.info("Cursor: Probe success - \(snapshot.quotas.count) quotas found") + return snapshot + } + + // MARK: - Token Extraction + + /// Reads the access token from Cursor's SQLite database using the sqlite3 CLI. + private func readAccessToken(from dbPath: String) throws -> String { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/sqlite3") + process.arguments = [dbPath, "SELECT value FROM ItemTable WHERE key = 'cursorAuth/accessToken'"] + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = Pipe() + + do { + try process.run() + process.waitUntilExit() + } catch { + AppLog.probes.error("Cursor: Failed to run sqlite3 - \(error.localizedDescription)") + throw ProbeError.executionFailed("Failed to read Cursor database: \(error.localizedDescription)") + } + + guard process.terminationStatus == 0 else { + AppLog.probes.error("Cursor: sqlite3 exited with status \(process.terminationStatus)") + throw ProbeError.executionFailed("sqlite3 exited with status \(process.terminationStatus)") + } + + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let token = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + + guard !token.isEmpty else { + AppLog.probes.error("Cursor: No access token found in database (not logged in?)") + throw ProbeError.authenticationRequired + } + + return token + } + + /// Extracts the user ID (`sub` claim) from a JWT token by base64-decoding the payload. + static func extractUserIdFromJWT(_ token: String) throws -> String { + let parts = token.split(separator: ".") + guard parts.count >= 2 else { + throw ProbeError.parseFailed("Invalid JWT format") + } + + // JWT payload is base64url-encoded + var base64 = String(parts[1]) + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + + // Pad to multiple of 4 + let remainder = base64.count % 4 + if remainder > 0 { + base64 += String(repeating: "=", count: 4 - remainder) + } + + guard let payloadData = Data(base64Encoded: base64) else { + throw ProbeError.parseFailed("Failed to decode JWT payload") + } + + guard let json = try? JSONSerialization.jsonObject(with: payloadData) as? [String: Any], + let sub = json["sub"] as? String, !sub.isEmpty else { + throw ProbeError.parseFailed("JWT payload missing 'sub' claim") + } + + return sub + } + + // MARK: - API Call + + private func fetchUsageSummary(cookie: String) async throws -> Data { + guard let url = URL(string: Self.usageSummaryURL) else { + throw ProbeError.executionFailed("Invalid URL") + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue(cookie, forHTTPHeaderField: "Cookie") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.timeoutInterval = timeout + + let (data, response) = try await networkClient.request(request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw ProbeError.executionFailed("Invalid response") + } + + AppLog.probes.debug("Cursor: API response status \(httpResponse.statusCode)") + + switch httpResponse.statusCode { + case 200: + return data + case 401: + AppLog.probes.error("Cursor: Authentication failed (401) - token may be expired") + throw ProbeError.sessionExpired + case 403: + AppLog.probes.error("Cursor: Forbidden (403)") + throw ProbeError.authenticationRequired + default: + AppLog.probes.error("Cursor: HTTP error \(httpResponse.statusCode)") + throw ProbeError.executionFailed("HTTP error: \(httpResponse.statusCode)") + } + } + + // MARK: - Response Parsing (static for testability) + + /// Parses the Cursor usage-summary API response into a UsageSnapshot. + /// + /// The API returns usage nested under `individualUsage.plan` and `individualUsage.onDemand`. + public static func parseUsageSummary(_ data: Data) throws -> UsageSnapshot { + let json: [String: Any] + do { + guard let parsed = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw ProbeError.parseFailed("Response is not a JSON object") + } + json = parsed + } catch let error as ProbeError { + throw error + } catch { + throw ProbeError.parseFailed("Invalid JSON: \(error.localizedDescription)") + } + + var quotas: [UsageQuota] = [] + + let membershipType = json["membershipType"] as? String ?? "unknown" + + // Parse billing cycle dates for reset time + var resetsAt: Date? + if let cycleEnd = json["billingCycleEnd"] as? String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = formatter.date(from: cycleEnd) { + resetsAt = date + } else { + // Try without fractional seconds + formatter.formatOptions = [.withInternetDateTime] + resetsAt = formatter.date(from: cycleEnd) + } + } + + // The API nests usage under "individualUsage" with "plan" and "onDemand" sub-objects + let individualUsage = json["individualUsage"] as? [String: Any] + + // Parse plan usage (included requests) + if let planUsage = individualUsage?["plan"] as? [String: Any], + let enabled = planUsage["enabled"] as? Bool, enabled { + let used = Self.intValue(from: planUsage, key: "used") ?? 0 + let limit = Self.intValue(from: planUsage, key: "limit") ?? 0 + + if limit > 0 { + let percentRemaining = Double(limit - used) / Double(limit) * 100 + let requestsText = "\(used)/\(limit) requests" + + quotas.append(UsageQuota( + percentRemaining: max(0, percentRemaining), + quotaType: .timeLimit("Monthly"), + providerId: "cursor", + resetsAt: resetsAt, + resetText: requestsText + )) + } + } + + // Parse on-demand usage (usage-based pricing) + if let onDemand = individualUsage?["onDemand"] as? [String: Any], + let enabled = onDemand["enabled"] as? Bool, enabled { + let used = Self.intValue(from: onDemand, key: "used") ?? 0 + let limit = Self.intValue(from: onDemand, key: "limit") ?? 0 + + if limit > 0 { + let percentRemaining = Double(limit - used) / Double(limit) * 100 + quotas.append(UsageQuota( + percentRemaining: max(0, percentRemaining), + quotaType: .timeLimit("On-Demand"), + providerId: "cursor", + resetsAt: resetsAt, + resetText: "\(used)/\(limit) on-demand" + )) + } + } + + // Check for unlimited plans + if let isUnlimited = json["isUnlimited"] as? Bool, isUnlimited { + quotas.append(UsageQuota( + percentRemaining: 100, + quotaType: .timeLimit("Monthly"), + providerId: "cursor", + resetText: "Unlimited" + )) + } + + // If no quotas found, the user might be on a free plan with no data + guard !quotas.isEmpty else { + throw ProbeError.parseFailed("No usage data found in Cursor response") + } + + // Determine account tier from membership type + let tier: AccountTier? = switch membershipType.lowercased() { + case "pro": .custom("PRO") + case "business": .custom("BUSINESS") + case "free": .custom("FREE") + case "ultra": .custom("ULTRA") + default: membershipType.isEmpty ? nil : .custom(membershipType.uppercased()) + } + + return UsageSnapshot( + providerId: "cursor", + quotas: quotas, + capturedAt: Date(), + accountTier: tier + ) + } + + /// Safely extracts an Int from a JSON dictionary value that could be Int, Double, or NSNumber. + private static func intValue(from dict: [String: Any], key: String) -> Int? { + if let intVal = dict[key] as? Int { + return intVal + } + if let doubleVal = dict[key] as? Double { + return Int(doubleVal) + } + return nil + } +} diff --git a/Tests/InfrastructureTests/Cursor/CursorUsageProbeParsingTests.swift b/Tests/InfrastructureTests/Cursor/CursorUsageProbeParsingTests.swift new file mode 100644 index 00000000..740ba836 --- /dev/null +++ b/Tests/InfrastructureTests/Cursor/CursorUsageProbeParsingTests.swift @@ -0,0 +1,411 @@ +import Foundation +import Testing +@testable import Infrastructure +@testable import Domain + +@Suite("CursorUsageProbe Parsing Tests") +struct CursorUsageProbeParsingTests { + + // MARK: - Real API Response + + @Test + func `parse real ultra plan response`() throws { + // Actual response from cursor.com/api/usage-summary + let json = """ + { + "billingCycleStart": "2026-02-06T03:34:49.000Z", + "billingCycleEnd": "2026-03-06T03:34:49.000Z", + "membershipType": "ultra", + "limitType": "user", + "isUnlimited": false, + "autoModelSelectedDisplayMessage": "You've used 1% of your included total usage", + "namedModelSelectedDisplayMessage": "You've used 1% of your included API usage", + "individualUsage": { + "plan": { + "enabled": true, + "used": 326, + "limit": 40000, + "remaining": 39674, + "breakdown": { "included": 326, "bonus": 0, "total": 326 }, + "autoPercentUsed": 0.033, + "apiPercentUsed": 0.586, + "totalPercentUsed": 0.217 + }, + "onDemand": { + "enabled": false, + "used": 0, + "limit": null, + "remaining": null + } + }, + "teamUsage": {} + } + """.data(using: .utf8)! + + let snapshot = try CursorUsageProbe.parseUsageSummary(json) + + #expect(snapshot.providerId == "cursor") + #expect(snapshot.quotas.count == 1) + #expect(snapshot.accountTier == .custom("ULTRA")) + + let quota = snapshot.quotas[0] + #expect(quota.quotaType == .timeLimit("Monthly")) + #expect(abs(quota.percentRemaining - 99.185) < 0.01) + #expect(quota.resetText == "326/40000 requests") + #expect(quota.resetsAt != nil) + } + + // MARK: - Plan Usage + + @Test + func `parse pro plan with plan usage`() throws { + let json = """ + { + "membershipType": "pro", + "billingCycleEnd": "2025-02-01T00:00:00Z", + "isUnlimited": false, + "individualUsage": { + "plan": { + "enabled": true, + "used": 123, + "limit": 500, + "remaining": 377 + }, + "onDemand": { "enabled": false, "used": 0, "limit": null, "remaining": null } + } + } + """.data(using: .utf8)! + + let snapshot = try CursorUsageProbe.parseUsageSummary(json) + + #expect(snapshot.providerId == "cursor") + #expect(snapshot.quotas.count == 1) + #expect(snapshot.accountTier == .custom("PRO")) + + let quota = snapshot.quotas[0] + #expect(quota.quotaType == .timeLimit("Monthly")) + #expect(abs(quota.percentRemaining - 75.4) < 0.1) + #expect(quota.resetText == "123/500 requests") + #expect(quota.resetsAt != nil) + } + + @Test + func `parse plan with on-demand usage enabled`() throws { + let json = """ + { + "membershipType": "pro", + "isUnlimited": false, + "individualUsage": { + "plan": { + "enabled": true, + "used": 400, + "limit": 500, + "remaining": 100 + }, + "onDemand": { + "enabled": true, + "used": 25, + "limit": 100, + "remaining": 75 + } + } + } + """.data(using: .utf8)! + + let snapshot = try CursorUsageProbe.parseUsageSummary(json) + + #expect(snapshot.quotas.count == 2) + + let plan = snapshot.quotas.first { $0.quotaType == .timeLimit("Monthly") } + #expect(plan != nil) + #expect(abs(plan!.percentRemaining - 20.0) < 0.1) + #expect(plan!.resetText == "400/500 requests") + + let onDemand = snapshot.quotas.first { $0.quotaType == .timeLimit("On-Demand") } + #expect(onDemand != nil) + #expect(abs(onDemand!.percentRemaining - 75.0) < 0.1) + } + + @Test + func `parse depleted plan usage`() throws { + let json = """ + { + "membershipType": "pro", + "isUnlimited": false, + "individualUsage": { + "plan": { + "enabled": true, + "used": 500, + "limit": 500, + "remaining": 0 + }, + "onDemand": { "enabled": false, "used": 0, "limit": null, "remaining": null } + } + } + """.data(using: .utf8)! + + let snapshot = try CursorUsageProbe.parseUsageSummary(json) + + #expect(snapshot.quotas.count == 1) + #expect(snapshot.quotas[0].percentRemaining == 0) + #expect(snapshot.quotas[0].resetText == "500/500 requests") + } + + @Test + func `parse over-limit usage clamps to zero`() throws { + let json = """ + { + "membershipType": "pro", + "isUnlimited": false, + "individualUsage": { + "plan": { + "enabled": true, + "used": 550, + "limit": 500, + "remaining": -50 + }, + "onDemand": { "enabled": false, "used": 0, "limit": null, "remaining": null } + } + } + """.data(using: .utf8)! + + let snapshot = try CursorUsageProbe.parseUsageSummary(json) + + #expect(snapshot.quotas.count == 1) + #expect(snapshot.quotas[0].percentRemaining == 0) + } + + // MARK: - Unlimited & Special Cases + + @Test + func `parse unlimited plan`() throws { + let json = """ + { + "membershipType": "business", + "isUnlimited": true, + "individualUsage": { + "plan": { "enabled": false }, + "onDemand": { "enabled": false, "used": 0, "limit": null, "remaining": null } + } + } + """.data(using: .utf8)! + + let snapshot = try CursorUsageProbe.parseUsageSummary(json) + + #expect(snapshot.quotas.count == 1) + #expect(snapshot.quotas[0].percentRemaining == 100) + #expect(snapshot.quotas[0].resetText == "Unlimited") + #expect(snapshot.accountTier == .custom("BUSINESS")) + } + + @Test + func `parse free plan`() throws { + let json = """ + { + "membershipType": "free", + "isUnlimited": false, + "individualUsage": { + "plan": { + "enabled": true, + "used": 30, + "limit": 50, + "remaining": 20 + }, + "onDemand": { "enabled": false, "used": 0, "limit": null, "remaining": null } + } + } + """.data(using: .utf8)! + + let snapshot = try CursorUsageProbe.parseUsageSummary(json) + + #expect(snapshot.accountTier == .custom("FREE")) + #expect(snapshot.quotas.count == 1) + #expect(abs(snapshot.quotas[0].percentRemaining - 40.0) < 0.1) + } + + // MARK: - Error Cases + + @Test + func `parse empty response throws error`() { + let json = "{}".data(using: .utf8)! + + #expect(throws: ProbeError.self) { + try CursorUsageProbe.parseUsageSummary(json) + } + } + + @Test + func `parse invalid json throws error`() { + let json = "not json".data(using: .utf8)! + + #expect(throws: ProbeError.self) { + try CursorUsageProbe.parseUsageSummary(json) + } + } + + @Test + func `parse response with no individualUsage and not unlimited throws error`() { + let json = """ + { + "membershipType": "pro", + "isUnlimited": false + } + """.data(using: .utf8)! + + #expect(throws: ProbeError.self) { + try CursorUsageProbe.parseUsageSummary(json) + } + } + + // MARK: - Billing Cycle + + @Test + func `parse billing cycle end with fractional seconds`() throws { + let json = """ + { + "membershipType": "pro", + "isUnlimited": false, + "billingCycleEnd": "2025-03-01T00:00:00.000Z", + "individualUsage": { + "plan": { + "enabled": true, + "used": 100, + "limit": 500, + "remaining": 400 + }, + "onDemand": { "enabled": false, "used": 0, "limit": null, "remaining": null } + } + } + """.data(using: .utf8)! + + let snapshot = try CursorUsageProbe.parseUsageSummary(json) + #expect(snapshot.quotas[0].resetsAt != nil) + } + + @Test + func `parse billing cycle end without fractional seconds`() throws { + let json = """ + { + "membershipType": "pro", + "isUnlimited": false, + "billingCycleEnd": "2025-03-01T00:00:00Z", + "individualUsage": { + "plan": { + "enabled": true, + "used": 100, + "limit": 500, + "remaining": 400 + }, + "onDemand": { "enabled": false, "used": 0, "limit": null, "remaining": null } + } + } + """.data(using: .utf8)! + + let snapshot = try CursorUsageProbe.parseUsageSummary(json) + #expect(snapshot.quotas[0].resetsAt != nil) + } + + // MARK: - JWT Parsing + + @Test + func `extract user ID from valid JWT`() throws { + // JWT with payload: {"sub": "user_abc123", "iat": 1234567890} + let header = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" + let payload = "eyJzdWIiOiJ1c2VyX2FiYzEyMyIsImlhdCI6MTIzNDU2Nzg5MH0" + let signature = "SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" + let jwt = "\(header).\(payload).\(signature)" + + let userId = try CursorUsageProbe.extractUserIdFromJWT(jwt) + #expect(userId == "user_abc123") + } + + @Test + func `extract user ID with pipe character like real Cursor JWTs`() throws { + // Cursor JWTs have sub like "github|user_01J6BBEPT2KSQKPPRGXDY8M1F4" + // Payload: {"sub": "github|user_01ABC", "type": "session"} + // base64url of {"sub":"github|user_01ABC","type":"session"} = + let payloadJson = #"{"sub":"github|user_01ABC","type":"session"}"# + let payloadBase64 = Data(payloadJson.utf8).base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + let jwt = "eyJhbGciOiJIUzI1NiJ9.\(payloadBase64).sig" + + let userId = try CursorUsageProbe.extractUserIdFromJWT(jwt) + #expect(userId == "github|user_01ABC") + } + + @Test + func `extract user ID from JWT with padding needed`() throws { + // Payload: {"sub": "u1"} + let header = "eyJhbGciOiJIUzI1NiJ9" + let payload = "eyJzdWIiOiJ1MSJ9" + let jwt = "\(header).\(payload).sig" + + let userId = try CursorUsageProbe.extractUserIdFromJWT(jwt) + #expect(userId == "u1") + } + + @Test + func `extract user ID from invalid JWT throws`() { + #expect(throws: ProbeError.self) { + try CursorUsageProbe.extractUserIdFromJWT("not-a-jwt") + } + } + + @Test + func `extract user ID from JWT without sub claim throws`() { + // Payload: {"iat": 123} (no sub) + let jwt = "eyJhbGciOiJIUzI1NiJ9.eyJpYXQiOjEyM30.sig" + + #expect(throws: ProbeError.self) { + try CursorUsageProbe.extractUserIdFromJWT(jwt) + } + } + + // MARK: - Numeric Type Handling + + @Test + func `parse usage values as doubles`() throws { + // Some API responses return numbers as doubles + let json = """ + { + "membershipType": "pro", + "isUnlimited": false, + "individualUsage": { + "plan": { + "enabled": true, + "used": 123.0, + "limit": 500.0, + "remaining": 377.0 + }, + "onDemand": { "enabled": false, "used": 0, "limit": null, "remaining": null } + } + } + """.data(using: .utf8)! + + let snapshot = try CursorUsageProbe.parseUsageSummary(json) + + #expect(snapshot.quotas.count == 1) + #expect(abs(snapshot.quotas[0].percentRemaining - 75.4) < 0.1) + } + + // MARK: - Account Tier Detection + + @Test + func `detect ultra tier`() throws { + let json = """ + { + "membershipType": "ultra", + "isUnlimited": false, + "individualUsage": { + "plan": { "enabled": true, "used": 1, "limit": 40000, "remaining": 39999 }, + "onDemand": { "enabled": false, "used": 0, "limit": null, "remaining": null } + } + } + """.data(using: .utf8)! + + let snapshot = try CursorUsageProbe.parseUsageSummary(json) + #expect(snapshot.accountTier == .custom("ULTRA")) + } +}