diff --git a/Sources/Infrastructure/Claude/ClaudeUsageProbe.swift b/Sources/Infrastructure/Claude/ClaudeUsageProbe.swift index 9179879..5ad4bc3 100644 --- a/Sources/Infrastructure/Claude/ClaudeUsageProbe.swift +++ b/Sources/Infrastructure/Claude/ClaudeUsageProbe.swift @@ -532,13 +532,39 @@ public final class ClaudeUsageProbe: UsageProbe, @unchecked Sendable { // Look for "resets" or time indicators like "2h" or "30m" if lower.contains("reset") || (lower.contains("in") && (lower.contains("h") || lower.contains("m"))) { - return candidate.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmed = candidate.trimmingCharacters(in: .whitespacesAndNewlines) + return deduplicateResetText(trimmed) } } } return nil } + /// Removes duplicate "Resets..." text caused by terminal redraw artifacts. + /// + /// The Claude CLI redraws the screen using cursor positioning. Wide Unicode characters + /// (progress bar blocks) can cause column misalignment, resulting in the reset text + /// being appended to itself on a single line, e.g.: + /// `"Resets 4:59pm (America/New_York)Resets 4:59pm (America/New_York)"` + /// + /// This method detects such duplication and returns only the last occurrence. + internal func deduplicateResetText(_ text: String) -> String { + // Find all positions where "Resets" (case-insensitive) starts in the original text + var positions: [Range] = [] + var searchStart = text.startIndex + while let range = text.range(of: "resets", options: .caseInsensitive, range: searchStart.. 1, let lastRange = positions.last { + return String(text[lastRange.lowerBound...]).trimmingCharacters(in: .whitespaces) + } + + return text + } + internal func extractEmail(text: String) -> String? { // Try old format first: "Account: email" or "Email: email" let oldPattern = #"(?i)(?:Account|Email):\s*([^\s@]+@[^\s@]+)"# @@ -602,6 +628,17 @@ public final class ClaudeUsageProbe: UsageProbe, @unchecked Sendable { internal func parseResetDate(_ text: String?) -> Date? { guard let text else { return nil } + // Try relative duration first: "2h 15m", "30m", "2d" + if let relativeDate = parseRelativeDuration(text) { + return relativeDate + } + + // Try absolute date/time: "4:59pm (America/New_York)", "Jan 15, 3:30pm (TZ)", etc. + return parseAbsoluteDate(text) + } + + /// Parses relative duration strings like "2h 15m", "30m", "2d" + private func parseRelativeDuration(_ text: String) -> Date? { var totalSeconds: TimeInterval = 0 // Extract days: "2d" or "2 d" or "2 days" @@ -635,6 +672,139 @@ public final class ClaudeUsageProbe: UsageProbe, @unchecked Sendable { return nil } + /// Parses absolute date/time strings from Claude CLI output. + /// + /// Handles these formats (all optionally followed by a timezone in parentheses): + /// - Time-only: "4:59pm", "3pm", "9pm" + /// - Month + day: "Dec 28" + /// - Month + day + time: "Jan 15, 3:30pm" or "Dec 25 at 4:59am" + /// - Month + day + year + time: "Jan 1, 2026 (America/New_York)" + private func parseAbsoluteDate(_ text: String) -> Date? { + // Extract timezone identifier from parentheses, e.g., "(America/New_York)" + let timeZone = extractTimeZone(from: text) + + // Strip everything up to and including the last "Resets" token (case-insensitive), + // then remove any trailing timezone in parentheses. + // Using the *last* occurrence handles both start-of-line "Resets Jan 1, 2026" + // and mid-line "$5.41 ... · Resets Jan 1, 2026 (America/New_York)". + var cleaned = text + if let lastResets = cleaned.range(of: "resets", options: [.caseInsensitive, .backwards]) { + cleaned = String(cleaned[lastResets.upperBound...]) + } + cleaned = cleaned + .replacingOccurrences(of: #"\s*\([^)]+\)\s*$"#, with: "", options: .regularExpression) + .trimmingCharacters(in: .whitespaces) + + // Normalize "at" separator: "Dec 25 at 4:59am" -> "Dec 25, 4:59am" + cleaned = cleaned.replacingOccurrences(of: #"\s+at\s+"#, with: ", ", options: .regularExpression) + + // Try date formats from most specific to least specific + let formats: [String] = [ + "MMM d, yyyy, h:mma", // "Jan 1, 2026, 3:30pm" (with year and minutes) + "MMM d, yyyy, ha", // "Jan 1, 2026, 3pm" (with year, no minutes) + "MMM d, yyyy", // "Jan 1, 2026" (date with year only) + "MMM d, h:mma", // "Jan 15, 3:30pm" (date with minutes) + "MMM d, ha", // "Jan 15, 4pm" (date without minutes) + "h:mma", // "4:59pm" (time-only with minutes) + "ha", // "3pm" (time-only, no minutes) + "MMM d", // "Dec 28" (date only) + ] + + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = timeZone ?? .current + + for format in formats { + formatter.dateFormat = format + if let date = formatter.date(from: cleaned) { + return resolveToFutureDate(date, format: format, timeZone: formatter.timeZone) + } + } + + return nil + } + + /// Extracts a timezone identifier from parenthesized text, e.g., "(America/New_York)" + private func extractTimeZone(from text: String) -> TimeZone? { + guard let match = text.range(of: #"\(([^)]+)\)"#, options: [.regularExpression, .backwards]) else { + return nil + } + let content = String(text[match]) + .dropFirst() // remove "(" + .dropLast() // remove ")" + let identifier = String(content).trimmingCharacters(in: .whitespaces) + return TimeZone(identifier: identifier) + } + + /// Resolves a parsed date to the next future occurrence. + /// + /// DateFormatter gives us a date with components that may be in the past + /// (e.g., "3pm" today but it's already 5pm, or "Dec 25" but it's Dec 26). + /// This method adjusts to the next occurrence. + private func resolveToFutureDate(_ parsedDate: Date, format: String, timeZone: TimeZone) -> Date { + var calendar = Calendar.current + calendar.timeZone = timeZone + let now = Date() + + let hasYear = format.contains("yyyy") + let hasMonth = format.contains("MMM") + let hasTime = format.contains("h") || format.contains("H") + + if hasYear { + // Explicit year provided — use as-is (e.g., "Jan 1, 2026") + return parsedDate + } + + if hasMonth && hasTime { + // Has month, day, and time (e.g., "Jan 15, 3:30pm") + // Set the year to current or next year + var components = calendar.dateComponents([.month, .day, .hour, .minute, .second], from: parsedDate) + components.year = calendar.component(.year, from: now) + if let candidate = calendar.date(from: components), candidate > now { + return candidate + } + // Already past this year — try next year + components.year = calendar.component(.year, from: now) + 1 + return calendar.date(from: components) ?? parsedDate + } + + if hasMonth { + // Date only, no time (e.g., "Dec 28") — assume start of day + var components = calendar.dateComponents([.month, .day], from: parsedDate) + components.hour = 0 + components.minute = 0 + components.second = 0 + components.year = calendar.component(.year, from: now) + if let candidate = calendar.date(from: components), candidate > now { + return candidate + } + components.year = calendar.component(.year, from: now) + 1 + return calendar.date(from: components) ?? parsedDate + } + + if hasTime { + // Time-only (e.g., "3pm", "4:59pm") — resolve to today or tomorrow + let parsedComponents = calendar.dateComponents([.hour, .minute, .second], from: parsedDate) + var todayComponents = calendar.dateComponents([.year, .month, .day], from: now) + todayComponents.hour = parsedComponents.hour + todayComponents.minute = parsedComponents.minute + todayComponents.second = parsedComponents.second + if let candidate = calendar.date(from: todayComponents), candidate > now { + return candidate + } + // Already past today — use tomorrow + if let tomorrow = calendar.date(byAdding: .day, value: 1, to: now) { + todayComponents = calendar.dateComponents([.year, .month, .day], from: tomorrow) + todayComponents.hour = parsedComponents.hour + todayComponents.minute = parsedComponents.minute + todayComponents.second = parsedComponents.second + return calendar.date(from: todayComponents) ?? parsedDate + } + } + + return parsedDate + } + // MARK: - Error Detection internal func extractUsageError(_ text: String) -> ProbeError? { diff --git a/Sources/Infrastructure/Gemini/GeminiAPIProbe.swift b/Sources/Infrastructure/Gemini/GeminiAPIProbe.swift index a4c4839..c15599b 100644 --- a/Sources/Infrastructure/Gemini/GeminiAPIProbe.swift +++ b/Sources/Infrastructure/Gemini/GeminiAPIProbe.swift @@ -174,11 +174,13 @@ internal struct GeminiAPIProbe { let quotas: [UsageQuota] = modelQuotaMap .sorted { $0.key < $1.key } .map { modelId, data in - UsageQuota( + let resetsAt = data.resetTime.flatMap { parseResetTime($0) } + return UsageQuota( percentRemaining: data.fraction * 100, quotaType: .modelSpecific(modelId), providerId: "gemini", - resetText: data.resetTime.map { "Resets \($0)" } + resetsAt: resetsAt, + resetText: formatResetText(resetsAt) ) } @@ -231,6 +233,36 @@ internal struct GeminiAPIProbe { ) } + // MARK: - Reset Time Parsing + + private func parseResetTime(_ value: String) -> Date? { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = formatter.date(from: value) { return date } + + // Try without fractional seconds + formatter.formatOptions = [.withInternetDateTime] + return formatter.date(from: value) + } + + private func formatResetText(_ date: Date?) -> String? { + guard let date else { return nil } + + let seconds = date.timeIntervalSinceNow + guard seconds > 0 else { return nil } + + let hours = Int(seconds / 3600) + let minutes = Int((seconds.truncatingRemainder(dividingBy: 3600)) / 60) + + if hours > 0 { + return "Resets in \(hours)h \(minutes)m" + } else if minutes > 0 { + return "Resets in \(minutes)m" + } else { + return "Resets soon" + } + } + private struct QuotaBucket: Decodable { let remainingFraction: Double? let resetTime: String? diff --git a/Tests/DomainTests/Provider/UsageQuotaTests.swift b/Tests/DomainTests/Provider/UsageQuotaTests.swift index 269e10e..5406cb1 100644 --- a/Tests/DomainTests/Provider/UsageQuotaTests.swift +++ b/Tests/DomainTests/Provider/UsageQuotaTests.swift @@ -46,8 +46,8 @@ struct UsageQuotaTests { @Test func `quota reset timestamp shows days hours and minutes`() { - // Given - 2 days, 5 hours, 30 minutes from now - let resetDate = Date().addingTimeInterval(2.0 * 86400 + 5.0 * 3600 + 30.0 * 60) + // Given - 2 days, 5 hours, 30 minutes from now (+ 30s buffer to avoid rounding down) + let resetDate = Date().addingTimeInterval(2.0 * 86400 + 5.0 * 3600 + 30.0 * 60 + 30) // When let quota = UsageQuota( @@ -63,8 +63,8 @@ struct UsageQuotaTests { @Test func `quota reset timestamp shows only hours and minutes when less than a day`() { - // Given - 3 hours, 15 minutes from now - let resetDate = Date().addingTimeInterval(3.0 * 3600 + 15.0 * 60) + // Given - 3 hours, 15 minutes from now (+ 30s buffer to avoid rounding down) + let resetDate = Date().addingTimeInterval(3.0 * 3600 + 15.0 * 60 + 30) // When let quota = UsageQuota( diff --git a/Tests/InfrastructureTests/Claude/ClaudeUsageProbeParsingTests.swift b/Tests/InfrastructureTests/Claude/ClaudeUsageProbeParsingTests.swift index cb0ce10..4ac1574 100644 --- a/Tests/InfrastructureTests/Claude/ClaudeUsageProbeParsingTests.swift +++ b/Tests/InfrastructureTests/Claude/ClaudeUsageProbeParsingTests.swift @@ -251,6 +251,60 @@ struct ClaudeUsageProbeParsingTests { } } + // MARK: - Absolute Reset Time Parsing (resetsAt populated) + + @Test + func `populates resetsAt for time only reset format`() throws { + // Given — Pro header with "Resets 4:59pm (America/New_York)" + let output = Self.proHeaderOutput + + // When + let snapshot = try ClaudeUsageProbe.parse(output) + + // Then — resetsAt must be a Date, not nil (enables pace tick) + let sessionQuota = snapshot.sessionQuota + #expect(sessionQuota?.resetsAt != nil, "resetsAt should be populated for 'Resets 4:59pm (TZ)' format") + #expect(sessionQuota?.percentTimeElapsed != nil, "percentTimeElapsed should be computable") + } + + @Test + func `populates resetsAt for date at time reset format`() throws { + // Given — real CLI output with "Resets Dec 25 at 4:59am (Asia/Shanghai)" + let output = Self.realCliOutput + + // When + let snapshot = try ClaudeUsageProbe.parse(output) + + // Then — both session and weekly should have resetsAt populated + #expect(snapshot.sessionQuota?.resetsAt != nil, "Session resetsAt should be populated for 'Resets 2:59pm (TZ)' format") + #expect(snapshot.weeklyQuota?.resetsAt != nil, "Weekly resetsAt should be populated for 'Resets Dec 25 at 4:59am (TZ)' format") + } + + @Test + func `populates resetsAt for date comma time format`() throws { + // Given — sample output with "Resets Jan 15, 3:30pm (America/Los_Angeles)" + let output = Self.sampleClaudeOutput + + // When + let snapshot = try ClaudeUsageProbe.parse(output) + + // Then — weekly and opus should have resetsAt populated (session uses relative "2h 15m" which already works) + #expect(snapshot.weeklyQuota?.resetsAt != nil, "Weekly resetsAt should be populated for 'Resets Jan 15, 3:30pm (TZ)' format") + } + + @Test + func `populates resetsAt for Claude API quotas with absolute times`() throws { + // Given — Claude API output with "Resets 9pm (Asia/Shanghai)" and "Resets Feb 12 at 4pm (Asia/Shanghai)" + let output = Self.claudeApiWithQuotasOutput + + // When + let snapshot = try ClaudeUsageProbe.parse(output) + + // Then — all quotas should have resetsAt populated + #expect(snapshot.sessionQuota?.resetsAt != nil, "Session resetsAt should be populated for 'Resets 9pm (TZ)' format") + #expect(snapshot.weeklyQuota?.resetsAt != nil, "Weekly resetsAt should be populated for 'Resets Feb 12 at 4pm (TZ)' format") + } + // MARK: - ANSI Code Handling static let ansiColoredOutput = """ @@ -532,6 +586,19 @@ struct ClaudeUsageProbeParsingTests { #expect(snapshot.quotas.count >= 1) } + @Test + func `parses resetsAt from Extra usage cost line with mid-line Resets`() throws { + // Given — "$5.41 / $20.00 spent · Resets Jan 1, 2026 (America/New_York)" + // "Resets" appears mid-line, not at the start + + // When + let snapshot = try ClaudeUsageProbe.parse(Self.proWithExtraUsageOutput) + + // Then — resetsAt should be populated even though "Resets" is mid-line + #expect(snapshot.costUsage?.resetsAt != nil, + "resetsAt should be populated when 'Resets' appears mid-line in cost line") + } + // MARK: - API Usage Billing Account Detection // Real output from API Usage Billing account showing subscription-only message @@ -765,6 +832,60 @@ struct ClaudeUsageProbeParsingTests { #expect(snapshot.weeklyQuota?.percentRemaining == 77) // 23% used = 77% remaining } + // MARK: - Terminal Rendering Deduplication + + @Test + func `handles duplicated reset text from terminal redraw artifact`() throws { + // Given - terminal rendering artifact where cursor misalignment causes + // reset text to appear twice on a single line + let output = """ + Opus 4.5 · Claude Pro · Organization + + Current session + █████░░░░░░░░░░░░░░░ 6% used + Resets 4:59pm (America/New_York)Resets 4:59pm (America/New_York) + + Current week (all models) + █████████████████░░░ 36% used + Resets Dec 25 at 2:59pm (America/New_York)Resets Dec 25 at 2:59pm (America/New_York) + """ + + // When + let snapshot = try ClaudeUsageProbe.parse(output) + + // Then — quotas should parse successfully with clean reset text + let session = snapshot.sessionQuota + #expect(session != nil) + #expect(session?.resetsAt != nil, "resetsAt should be populated despite duplicated text") + #expect(session?.resetText?.contains("Resets 4:59pm") == true) + // Should NOT contain the duplication + #expect(session?.resetText?.components(separatedBy: "Resets").count == 2, + "resetText should contain 'Resets' exactly once (prefix + content)") + + let weekly = snapshot.weeklyQuota + #expect(weekly != nil) + #expect(weekly?.resetsAt != nil, "weekly resetsAt should be populated despite duplicated text") + } + + @Test + func `extractReset returns clean text when line has duplicate from terminal redraw`() throws { + // Given + let probe = ClaudeUsageProbe() + let text = """ + Current session + ████ 6% used + Resets 4:59pm (America/New_York)Resets 4:59pm (America/New_York) + """ + + // When + let result = probe.extractReset(labelSubstring: "Current session", text: text) + + // Then — should return deduplicated text + let unwrapped = try #require(result) + let resetsCount = unwrapped.components(separatedBy: "Resets").count - 1 + #expect(resetsCount == 1, "Should contain 'Resets' exactly once, got \(resetsCount) in: \(unwrapped)") + } + // MARK: - Helper private func simulateParse(text: String) throws -> UsageSnapshot { diff --git a/Tests/InfrastructureTests/Claude/ClaudeUsageProbeTests.swift b/Tests/InfrastructureTests/Claude/ClaudeUsageProbeTests.swift index a8e25cc..e27c570 100644 --- a/Tests/InfrastructureTests/Claude/ClaudeUsageProbeTests.swift +++ b/Tests/InfrastructureTests/Claude/ClaudeUsageProbeTests.swift @@ -62,6 +62,93 @@ struct ClaudeUsageProbeTests { #expect(probe.parseResetDate("no time here") == nil) } + // MARK: - Absolute Time Parsing Tests + + @Test + func `parses reset date with time only and timezone`() { + let probe = ClaudeUsageProbe() + + // "Resets 4:59pm (America/New_York)" — should resolve to a Date today or tomorrow + let result = probe.parseResetDate("Resets 4:59pm (America/New_York)") + #expect(result != nil, "Should parse time-only format with timezone") + if let date = result { + // Should be within the next 24 hours + let diff = date.timeIntervalSinceNow + #expect(diff > -60) // Allow small margin for test execution + #expect(diff < 24 * 3600 + 60) + } + } + + @Test + func `parses reset date with short time and timezone`() { + let probe = ClaudeUsageProbe() + + // "Resets 3pm (Asia/Shanghai)" — short time without minutes + let result = probe.parseResetDate("Resets 3pm (Asia/Shanghai)") + #expect(result != nil, "Should parse short time format like 3pm") + } + + @Test + func `parses reset date with month day and time with timezone`() { + let probe = ClaudeUsageProbe() + + // "Resets Dec 25 at 4:59am (Asia/Shanghai)" + let result = probe.parseResetDate("Resets Dec 25 at 4:59am (Asia/Shanghai)") + #expect(result != nil, "Should parse 'Mon DD at H:MMam (TZ)' format") + } + + @Test + func `parses reset date with month day comma time and timezone`() { + let probe = ClaudeUsageProbe() + + // "Resets Jan 15, 3:30pm (America/Los_Angeles)" + let result = probe.parseResetDate("Resets Jan 15, 3:30pm (America/Los_Angeles)") + #expect(result != nil, "Should parse 'Mon DD, H:MMpm (TZ)' format") + } + + @Test + func `parses reset date with month day comma time without timezone`() { + let probe = ClaudeUsageProbe() + + // "Resets Jan 15, 3:30pm" + let result = probe.parseResetDate("Resets Jan 15, 3:30pm") + #expect(result != nil, "Should parse 'Mon DD, H:MMpm' without timezone") + } + + @Test + func `parses reset date with month day year and timezone`() { + let probe = ClaudeUsageProbe() + + // Always use a future year so the test never goes stale + let futureYear = Calendar.current.component(.year, from: Date()) + 1 + let result = probe.parseResetDate("Resets Jan 1, \(futureYear) (America/New_York)") + #expect(result != nil, "Should parse 'Mon DD, YYYY (TZ)' format") + } + + @Test + func `parses reset date with month day only`() { + let probe = ClaudeUsageProbe() + + // "Resets Dec 28" + let result = probe.parseResetDate("Resets Dec 28") + #expect(result != nil, "Should parse 'Mon DD' date-only format") + } + + @Test + func `parsed absolute date has correct timezone`() { + let probe = ClaudeUsageProbe() + + // Two calls with different timezones for the same time should yield different Dates + let eastern = probe.parseResetDate("Resets 4:59pm (America/New_York)") + let shanghai = probe.parseResetDate("Resets 4:59pm (Asia/Shanghai)") + #expect(eastern != nil) + #expect(shanghai != nil) + if let e = eastern, let s = shanghai { + // These should NOT be equal — different timezones for the same wall-clock time + #expect(e != s, "Same wall-clock time in different timezones should produce different Dates") + } + } + // MARK: - Helper Tests @Test diff --git a/Tests/InfrastructureTests/Gemini/GeminiAPIProbeTests.swift b/Tests/InfrastructureTests/Gemini/GeminiAPIProbeTests.swift index febf5e9..c193275 100644 --- a/Tests/InfrastructureTests/Gemini/GeminiAPIProbeTests.swift +++ b/Tests/InfrastructureTests/Gemini/GeminiAPIProbeTests.swift @@ -120,6 +120,70 @@ struct GeminiAPIProbeTests { .called(1) } + @Test + func `probe parses reset time into Date and human readable text`() async throws { + let homeDir = try makeTemporaryHomeDirectory() + try createCredentialsFile(in: homeDir) + let mockService = MockNetworkClient() + + let projectsResponse = """ + { + "projects": [ + { "projectId": "gen-lang-client-123456" } + ] + } + """.data(using: .utf8)! + + // Use a reset time 2 hours in the future + let futureDate = Date().addingTimeInterval(2 * 3600 + 15 * 60) // 2h 15m from now + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + let resetTimeString = formatter.string(from: futureDate) + + let quotaResponse = """ + { + "buckets": [ + { + "modelId": "gemini-pro", + "remainingFraction": 0.8, + "resetTime": "\(resetTimeString)" + } + ] + } + """.data(using: .utf8)! + + given(mockService) + .request(.any) + .willProduce { request in + let url = request.url?.absoluteString ?? "" + if url.contains("projects") { + return (projectsResponse, HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!) + } else { + return (quotaResponse, HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!) + } + } + + let probe = GeminiAPIProbe( + homeDirectory: homeDir.path, + timeout: 1.0, + networkClient: mockService, + maxRetries: 1 + ) + + let snapshot = try await probe.probe() + let quota = try #require(snapshot.quotas.first) + + // resetsAt should be a parsed Date, not nil + let resetsAt = try #require(quota.resetsAt) + let timeDiff = abs(resetsAt.timeIntervalSince(futureDate)) + #expect(timeDiff < 2) // Within 2 seconds tolerance + + // resetText should be human-readable, not raw ISO 8601 + let resetText = try #require(quota.resetText) + #expect(resetText.contains("Resets in")) + #expect(!resetText.contains("T")) // Should NOT contain ISO 8601 'T' separator + } + @Test func `probe handles api error gracefully`() async throws { let homeDir = try makeTemporaryHomeDirectory()