import Foundation import Testing @testable import CodexBarCore struct CostUsageScannerTests { @Test func `codex session metadata skips an oversized line without retaining it`() throws { let env = try CostUsageTestEnvironment() defer { env.cleanup() } let fileURL = env.root.appendingPathComponent("oversized-session-meta.jsonl") FileManager.default.createFile(atPath: fileURL.path, contents: nil) let handle = try FileHandle(forWritingTo: fileURL) defer { try? handle.close() } let oversizedPrefix = "{\"type\":\"session_meta\",\"payload\":{\"id\":\"too-large\",\"padding\":\"" try handle.write(contentsOf: Data(oversizedPrefix.utf8)) let chunk = Data(repeating: 0x78, count: 64 * 1024) for _ in 0..<128 { try handle.write(contentsOf: chunk) } let expectedLine = #"{"type":"session_meta","payload":{"id":"expected-session"}}"# try handle.write(contentsOf: Data((#""}}"# + "\n" + expectedLine).utf8)) try handle.close() let sessionID = try CostUsageScanner.parseCodexSessionIdentifier(fileURL: fileURL) #expect(sessionID == "expected-session") } @Test func `codex session metadata accepts a line exactly at the byte limit`() throws { let env = try CostUsageTestEnvironment() defer { env.cleanup() } let prefix = "{\"type\":\"session_meta\",\"payload\":{\"id\":\"limit-session\",\"padding\":\"" let suffix = "\"}}" let paddingCount = CostUsageScanner.codexSessionMetadataMaxLineBytes - prefix.utf8.count - suffix.utf8.count var line = Data(prefix.utf8) line.append(Data(repeating: 0x78, count: paddingCount)) line.append(contentsOf: suffix.utf8) #expect(line.count == CostUsageScanner.codexSessionMetadataMaxLineBytes) let fileURL = env.root.appendingPathComponent("max-size-session-meta.jsonl") try line.write(to: fileURL) #expect(try (JSONSerialization.jsonObject(with: line)) is [String: Any]) let sessionID = try CostUsageScanner.parseCodexSessionIdentifier(fileURL: fileURL) #expect(sessionID == "limit-session") } @Test func `codex file metadata detects append truncation and replacement`() throws { let root = FileManager.default.temporaryDirectory .appendingPathComponent("codexbar-codex-metadata-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: root) } let fileURL = root.appendingPathComponent("session.jsonl") try Data("abc".utf8).write(to: fileURL) let initial = CostUsageScanner.codexFileMetadata(fileURL: fileURL) #expect(initial.size == 3) #expect(initial.fileId != nil) let linkURL = root.appendingPathComponent("linked-session.jsonl") try FileManager.default.createSymbolicLink(at: linkURL, withDestinationURL: fileURL) let linked = CostUsageScanner.codexFileMetadata(fileURL: linkURL) #expect(linked.size == initial.size) #expect(linked.fileId == initial.fileId) let handle = try FileHandle(forWritingTo: fileURL) try handle.seekToEnd() try handle.write(contentsOf: Data("def".utf8)) try handle.close() let appended = CostUsageScanner.codexFileMetadata(fileURL: fileURL) #expect(appended.size == 6) #expect(appended.fileId == initial.fileId) let truncateHandle = try FileHandle(forWritingTo: fileURL) try truncateHandle.truncate(atOffset: 2) try truncateHandle.close() let truncated = CostUsageScanner.codexFileMetadata(fileURL: fileURL) #expect(truncated.size == 2) #expect(truncated.fileId == initial.fileId) try FileManager.default.removeItem(at: fileURL) try Data("replacement".utf8).write(to: fileURL) let replaced = CostUsageScanner.codexFileMetadata(fileURL: fileURL) #expect(replaced.size == 11) #expect(replaced.fileId != initial.fileId) } @Test func `vertex daily report filters claude logs`() throws { let env = try CostUsageTestEnvironment() defer { env.cleanup() } let day = try env.makeLocalNoon(year: 2025, month: 12, day: 20) let iso0 = env.isoString(for: day) let vertexEntry: [String: Any] = [ "type": "assistant", "timestamp": iso0, "metadata": [ "provider": "vertexai", "projectId": "vertex-project", "location": "us-central1", ], "message": [ "model": "claude-sonnet-4-20250514", "usage": [ "input_tokens": 10, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "output_tokens": 5, ], ], ] let claudeEntry: [String: Any] = [ "type": "assistant", "timestamp": iso0, "metadata": [ "provider": "anthropic", ], "message": [ "model": "claude-sonnet-4-20250514", "usage": [ "input_tokens": 200, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "output_tokens": 100, ], ], ] _ = try env.writeClaudeProjectFile( relativePath: "project-a/session-a.jsonl", contents: env.jsonl([vertexEntry, claudeEntry])) var options = CostUsageScanner.Options( codexSessionsRoot: nil, claudeProjectsRoots: [env.claudeProjectsRoot], cacheRoot: env.cacheRoot) options.refreshMinIntervalSeconds = 0 let report = CostUsageScanner.loadDailyReport( provider: .vertexai, since: day, until: day, now: day, options: options) #expect(report.data.count == 1) #expect(report.data[0].inputTokens == 10) #expect(report.data[0].outputTokens == 5) #expect(report.data[0].totalTokens == 15) } @Test func `vertex daily report detects by vrtx id prefix`() throws { let env = try CostUsageTestEnvironment() defer { env.cleanup() } let day = try env.makeLocalNoon(year: 2025, month: 12, day: 20) let iso0 = env.isoString(for: day) let iso1 = env.isoString(for: day.addingTimeInterval(1)) // Vertex AI entries have "_vrtx_" in message.id and requestId let vertexEntry: [String: Any] = [ "type": "assistant", "timestamp": iso0, "requestId": "req_vrtx_011CWjK86SWeFuXqZKUtgB1H", "message": [ "id": "msg_vrtx_0154LUXjFVzQGUca3yK2RUeo", "model": "claude-opus-4-5-20251101", "usage": [ "input_tokens": 100, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "output_tokens": 50, ], ], ] // Anthropic API entries have regular IDs without "_vrtx_" let claudeEntry: [String: Any] = [ "type": "assistant", "timestamp": iso1, "requestId": "req_011CW7BFSFkbK9qJrV8kiptH", "message": [ "id": "msg_0152zX6DsQYcwH1qiXi4B3y2", "model": "claude-opus-4-5-20251101", "usage": [ "input_tokens": 200, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "output_tokens": 100, ], ], ] _ = try env.writeClaudeProjectFile( relativePath: "project-a/session-a.jsonl", contents: env.jsonl([vertexEntry, claudeEntry])) var options = CostUsageScanner.Options( codexSessionsRoot: nil, claudeProjectsRoots: [env.claudeProjectsRoot], cacheRoot: env.cacheRoot) options.refreshMinIntervalSeconds = 0 // Vertex AI report should only include entries with _vrtx_ prefix let vertexReport = CostUsageScanner.loadDailyReport( provider: .vertexai, since: day, until: day, now: day, options: options) #expect(vertexReport.data.count == 1) #expect(vertexReport.data[0].inputTokens == 100) #expect(vertexReport.data[0].outputTokens == 50) #expect(vertexReport.data[0].totalTokens == 150) // Claude report with excludeVertexAI should only include non-vrtx entries var claudeOptions = options claudeOptions.claudeLogProviderFilter = .excludeVertexAI let claudeReport = CostUsageScanner.loadDailyReport( provider: .claude, since: day, until: day, now: day, options: claudeOptions) #expect(claudeReport.data.count == 1) #expect(claudeReport.data[0].inputTokens == 200) #expect(claudeReport.data[0].outputTokens == 100) #expect(claudeReport.data[0].totalTokens == 300) } @Test func `claude report preserves per-request threshold pricing`() throws { let env = try CostUsageTestEnvironment() defer { env.cleanup() } let day = try env.makeLocalNoon(year: 2026, month: 5, day: 9) let first = env.isoString(for: day) let second = env.isoString(for: day.addingTimeInterval(1)) let model = "claude-sonnet-4-5" let firstEntry: [String: Any] = [ "type": "assistant", "timestamp": first, "requestId": "req_one", "message": [ "id": "msg_one", "model": model, "usage": [ "input_tokens": 150_000, "output_tokens": 0, ], ], ] let secondEntry: [String: Any] = [ "type": "assistant", "timestamp": second, "requestId": "req_two", "message": [ "id": "msg_two", "model": model, "usage": [ "input_tokens": 150_000, "output_tokens": 0, ], ], ] _ = try env.writeClaudeProjectFile( relativePath: "project-a/threshold.jsonl", contents: env.jsonl([firstEntry, secondEntry])) var options = CostUsageScanner.Options( codexSessionsRoot: nil, claudeProjectsRoots: [env.claudeProjectsRoot], cacheRoot: env.cacheRoot) options.refreshMinIntervalSeconds = 0 let report = CostUsageScanner.loadDailyReport( provider: .claude, since: day, until: day, now: day, options: options) let expectedRequestCost = CostUsagePricing.claudeCostUSD( model: model, inputTokens: 150_000, cacheReadInputTokens: 0, cacheCreationInputTokens: 0, outputTokens: 0, modelsDevCacheRoot: env.cacheRoot) ?? 0 let aggregateCost = CostUsagePricing.claudeCostUSD( model: model, inputTokens: 300_000, cacheReadInputTokens: 0, cacheCreationInputTokens: 0, outputTokens: 0, modelsDevCacheRoot: env.cacheRoot) ?? 0 let expectedCost = expectedRequestCost * 2 #expect(report.data.count == 1) #expect(report.data.first?.inputTokens == 300_000) #expect(abs((report.data.first?.costUSD ?? 0) - expectedCost) < 0.000001) #expect(abs((report.data.first?.costUSD ?? 0) - aggregateCost) > 0.000001) #expect(abs((report.data.first?.modelBreakdowns?.first?.costUSD ?? 0) - expectedCost) < 0.000001) } @Test func `claude parses large lines with usage at tail`() throws { let env = try CostUsageTestEnvironment() defer { env.cleanup() } let day = try env.makeLocalNoon(year: 2025, month: 12, day: 28) let iso0 = env.isoString(for: day) let largeText = String(repeating: "a", count: 70000) let assistant: [String: Any] = [ "type": "assistant", "timestamp": iso0, "message": [ "model": "claude-sonnet-4-20250514", "content": [ ["type": "text", "text": largeText], ], "usage": [ "input_tokens": 3714, "output_tokens": 1, ], ], ] _ = try env.writeClaudeProjectFile( relativePath: "project-a/large-line.jsonl", contents: env.jsonl([assistant])) var options = CostUsageScanner.Options( codexSessionsRoot: nil, claudeProjectsRoots: [env.claudeProjectsRoot], cacheRoot: env.cacheRoot) options.refreshMinIntervalSeconds = 0 let report = CostUsageScanner.loadDailyReport( provider: .claude, since: day, until: day, now: day, options: options) #expect(report.data.count == 1) #expect(report.data[0].inputTokens == 3714) #expect(report.data[0].outputTokens == 1) #expect(report.data[0].totalTokens == 3715) } @Test func `claude daily report refreshes when file changes`() throws { let env = try CostUsageTestEnvironment() defer { env.cleanup() } let day = try env.makeLocalNoon(year: 2025, month: 12, day: 20) let iso0 = env.isoString(for: day) let iso1 = env.isoString(for: day.addingTimeInterval(1)) let model = "claude-sonnet-4-20250514" let first: [String: Any] = [ "type": "assistant", "timestamp": iso0, "message": [ "model": model, "usage": [ "input_tokens": 200, "cache_creation_input_tokens": 50, "cache_read_input_tokens": 25, "output_tokens": 80, ], ], ] let fileURL = try env.writeClaudeProjectFile( relativePath: "project-a/session-a.jsonl", contents: env.jsonl([first])) var options = CostUsageScanner.Options( codexSessionsRoot: nil, claudeProjectsRoots: [env.claudeProjectsRoot], cacheRoot: env.cacheRoot) options.refreshMinIntervalSeconds = 0 let firstReport = CostUsageScanner.loadDailyReport( provider: .claude, since: day, until: day, now: day, options: options) #expect(firstReport.data.first?.totalTokens == 355) let second: [String: Any] = [ "type": "assistant", "timestamp": iso1, "message": [ "model": model, "usage": [ "input_tokens": 40, "cache_creation_input_tokens": 10, "cache_read_input_tokens": 5, "output_tokens": 20, ], ], ] try env.jsonl([first, second]).write(to: fileURL, atomically: true, encoding: .utf8) let secondReport = CostUsageScanner.loadDailyReport( provider: .claude, since: day, until: day, now: day, options: options) #expect(secondReport.data.first?.totalTokens == 430) } @Test func `codex incremental parsing uses previous totals`() throws { let env = try CostUsageTestEnvironment() defer { env.cleanup() } let day = try env.makeLocalNoon(year: 2025, month: 12, day: 20) let iso0 = env.isoString(for: day) let iso1 = env.isoString(for: day.addingTimeInterval(1)) let iso2 = env.isoString(for: day.addingTimeInterval(2)) let model = "openai/gpt-5.2-codex" let normalized = CostUsagePricing.normalizeCodexModel(model) #expect(normalized == "gpt-5.2-codex") let turnContext: [String: Any] = [ "type": "turn_context", "timestamp": iso0, "payload": [ "model": model, ], ] let firstTokenCount: [String: Any] = [ "type": "event_msg", "timestamp": iso1, "payload": [ "type": "token_count", "info": [ "total_token_usage": [ "input_tokens": 100, "cached_input_tokens": 20, "output_tokens": 10, ], "model": model, ], ], ] let fileURL = try env.writeCodexSessionFile( day: day, filename: "session.jsonl", contents: env.jsonl([turnContext, firstTokenCount])) let range = CostUsageScanner.CostUsageDayRange(since: day, until: day) let first = CostUsageScanner.parseCodexFile(fileURL: fileURL, range: range) #expect(first.parsedBytes > 0) #expect(first.lastTotals?.input == 100) #expect(first.lastTotals?.cached == 20) #expect(first.lastTotals?.output == 10) let secondTokenCount: [String: Any] = [ "type": "event_msg", "timestamp": iso2, "payload": [ "type": "token_count", "info": [ "total_token_usage": [ "input_tokens": 160, "cached_input_tokens": 40, "output_tokens": 16, ], "model": model, ], ], ] try env.jsonl([turnContext, firstTokenCount, secondTokenCount]) .write(to: fileURL, atomically: true, encoding: .utf8) let delta = CostUsageScanner.parseCodexFile( fileURL: fileURL, range: range, startOffset: first.parsedBytes, initialModel: first.lastModel, initialTotals: first.lastTotals) let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) let packed = delta.days[dayKey]?[normalized] ?? [] #expect(packed.count >= 3) #expect(packed[0] == 60) #expect(packed[1] == 20) #expect(packed[2] == 6) } @Test func `codex incremental parsing keeps current turn id`() throws { let env = try CostUsageTestEnvironment() defer { env.cleanup() } let day = try env.makeLocalNoon(year: 2026, month: 5, day: 10) let iso0 = env.isoString(for: day) let iso1 = env.isoString(for: day.addingTimeInterval(1)) let iso2 = env.isoString(for: day.addingTimeInterval(2)) let iso3 = env.isoString(for: day.addingTimeInterval(3)) let model = "openai/gpt-5.5" let turnID = "22222222-2222-2222-2222-222222222222" let turnContext: [String: Any] = [ "type": "turn_context", "timestamp": iso0, "payload": [ "model": model, ], ] let taskStarted: [String: Any] = [ "type": "event_msg", "timestamp": iso1, "payload": [ "type": "task_started", "id": turnID, ], ] let firstTokenCount: [String: Any] = [ "type": "event_msg", "timestamp": iso2, "payload": [ "type": "token_count", "info": [ "total_token_usage": [ "input_tokens": 100, "cached_input_tokens": 20, "output_tokens": 10, ], ], ], ] let fileURL = try env.writeCodexSessionFile( day: day, filename: "priority-session.jsonl", contents: env.jsonl([turnContext, taskStarted, firstTokenCount])) let range = CostUsageScanner.CostUsageDayRange(since: day, until: day) let first = CostUsageScanner.parseCodexFile(fileURL: fileURL, range: range) #expect(first.lastCodexTurnID == turnID) #expect(first.rows.map(\.turnID) == [turnID]) let secondTokenCount: [String: Any] = [ "type": "event_msg", "timestamp": iso3, "payload": [ "type": "token_count", "info": [ "total_token_usage": [ "input_tokens": 160, "cached_input_tokens": 40, "output_tokens": 16, ], ], ], ] try env.jsonl([turnContext, taskStarted, firstTokenCount, secondTokenCount]) .write(to: fileURL, atomically: true, encoding: .utf8) let delta = CostUsageScanner.parseCodexFile( fileURL: fileURL, range: range, startOffset: first.parsedBytes, initialModel: first.lastModel, initialTotals: first.lastTotals, initialCodexTurnID: first.lastCodexTurnID) #expect(delta.lastCodexTurnID == turnID) #expect(delta.rows.map(\.turnID) == [turnID]) #expect(delta.rows.first?.input == 60) #expect(delta.rows.first?.cached == 20) #expect(delta.rows.first?.output == 6) } @Test func `codex fast parser does not trap on overflowing token integers`() throws { let env = try CostUsageTestEnvironment() defer { env.cleanup() } let day = try env.makeLocalNoon(year: 2026, month: 5, day: 10) let iso = env.isoString(for: day) let hugeInteger = String(repeating: "9", count: 100) let line = """ {"type":"event_msg","timestamp":"\( iso)","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":\( hugeInteger),"cached_input_tokens":0,"output_tokens":5},"model":"openai/gpt-5.5"}}} """ let fileURL = try env.writeCodexSessionFile(day: day, filename: "overflow.jsonl", contents: line + "\n") let range = CostUsageScanner.CostUsageDayRange(since: day, until: day) let parsed = CostUsageScanner.parseCodexFile(fileURL: fileURL, range: range) let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) let packed = parsed.days[dayKey]?["gpt-5.5"] ?? [] #expect(packed.count >= 3) #expect(packed[0] == 0) #expect(packed[1] == 0) #expect(packed[2] == 5) } @Test func `claude incremental parsing reads appended lines only`() throws { let env = try CostUsageTestEnvironment() defer { env.cleanup() } let day = try env.makeLocalNoon(year: 2025, month: 12, day: 20) let iso0 = env.isoString(for: day) let iso1 = env.isoString(for: day.addingTimeInterval(1)) let model = "claude-sonnet-4-20250514" let normalized = CostUsagePricing.normalizeClaudeModel(model) let first: [String: Any] = [ "type": "assistant", "timestamp": iso0, "message": [ "model": model, "usage": [ "input_tokens": 200, "cache_creation_input_tokens": 50, "cache_read_input_tokens": 25, "output_tokens": 80, ], ], ] let fileURL = try env.writeClaudeProjectFile( relativePath: "project-a/session-a.jsonl", contents: env.jsonl([first])) let range = CostUsageScanner.CostUsageDayRange(since: day, until: day) let firstParse = CostUsageScanner.parseClaudeFile( fileURL: fileURL, range: range, providerFilter: .all) #expect(firstParse.parsedBytes > 0) let second: [String: Any] = [ "type": "assistant", "timestamp": iso1, "message": [ "model": model, "usage": [ "input_tokens": 40, "cache_creation_input_tokens": 10, "cache_read_input_tokens": 5, "output_tokens": 20, ], ], ] try env.jsonl([first, second]).write(to: fileURL, atomically: true, encoding: .utf8) let delta = CostUsageScanner.parseClaudeFile( fileURL: fileURL, range: range, providerFilter: .all, startOffset: firstParse.parsedBytes) let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) let packed = delta.days[dayKey]?[normalized] ?? [] #expect(packed.count >= 4) #expect(packed[0] == 40) #expect(packed[1] == 5) #expect(packed[2] == 10) #expect(packed[3] == 20) } @Test func `day key from timestamp matches ISO parsing`() { let timestamps = [ "2025-12-20T23:59:59Z", "2025-12-20T23:59:59+02:00", ] for ts in timestamps { let expected = CostUsageScanner.dayKeyFromParsedISO(ts) let fast = CostUsageScanner.dayKeyFromTimestamp(ts) #expect(fast == expected) } } @Test func `claude deduplicates streaming chunks`() throws { let env = try CostUsageTestEnvironment() defer { env.cleanup() } let day = try env.makeLocalNoon(year: 2025, month: 12, day: 20) let iso0 = env.isoString(for: day) let iso1 = env.isoString(for: day.addingTimeInterval(1)) let iso2 = env.isoString(for: day.addingTimeInterval(2)) let model = "claude-sonnet-4-20250514" let messageId = "msg_01ABC123" let requestId = "req_01XYZ789" // Streaming emits multiple chunks with same message.id + requestId. // Each chunk has cumulative usage, not delta. let chunk1: [String: Any] = [ "type": "assistant", "timestamp": iso0, "requestId": requestId, "message": [ "id": messageId, "model": model, "usage": [ "input_tokens": 100, "cache_creation_input_tokens": 50, "cache_read_input_tokens": 25, "output_tokens": 10, ], ], ] let chunk2: [String: Any] = [ "type": "assistant", "timestamp": iso1, "requestId": requestId, "message": [ "id": messageId, "model": model, "usage": [ "input_tokens": 100, "cache_creation_input_tokens": 50, "cache_read_input_tokens": 25, "output_tokens": 10, ], ], ] let chunk3: [String: Any] = [ "type": "assistant", "timestamp": iso2, "requestId": requestId, "message": [ "id": messageId, "model": model, "usage": [ "input_tokens": 100, "cache_creation_input_tokens": 50, "cache_read_input_tokens": 25, "output_tokens": 10, ], ], ] _ = try env.writeClaudeProjectFile( relativePath: "project-a/session-a.jsonl", contents: env.jsonl([chunk1, chunk2, chunk3])) var options = CostUsageScanner.Options( codexSessionsRoot: nil, claudeProjectsRoots: [env.claudeProjectsRoot], cacheRoot: env.cacheRoot) options.refreshMinIntervalSeconds = 0 let report = CostUsageScanner.loadDailyReport( provider: .claude, since: day, until: day, now: day, options: options) // Should only count once, not 3x. #expect(report.data.count == 1) #expect(report.data[0].inputTokens == 100) #expect(report.data[0].cacheCreationTokens == 50) #expect(report.data[0].cacheReadTokens == 25) #expect(report.data[0].outputTokens == 10) #expect(report.data[0].totalTokens == 185) } @Test func `claude counts entries without ids as separate`() throws { let env = try CostUsageTestEnvironment() defer { env.cleanup() } let day = try env.makeLocalNoon(year: 2025, month: 12, day: 20) let iso0 = env.isoString(for: day) let iso1 = env.isoString(for: day.addingTimeInterval(1)) let model = "claude-sonnet-4-20250514" // Entries without message.id or requestId should still be counted // (fallback for older log formats). let entry1: [String: Any] = [ "type": "assistant", "timestamp": iso0, "message": [ "model": model, "usage": [ "input_tokens": 100, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "output_tokens": 50, ], ], ] let entry2: [String: Any] = [ "type": "assistant", "timestamp": iso1, "message": [ "model": model, "usage": [ "input_tokens": 200, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "output_tokens": 100, ], ], ] _ = try env.writeClaudeProjectFile( relativePath: "project-a/session-a.jsonl", contents: env.jsonl([entry1, entry2])) var options = CostUsageScanner.Options( codexSessionsRoot: nil, claudeProjectsRoots: [env.claudeProjectsRoot], cacheRoot: env.cacheRoot) options.refreshMinIntervalSeconds = 0 let report = CostUsageScanner.loadDailyReport( provider: .claude, since: day, until: day, now: day, options: options) // Both entries should be counted since no IDs to dedupe. #expect(report.data.count == 1) #expect(report.data[0].inputTokens == 300) #expect(report.data[0].outputTokens == 150) #expect(report.data[0].totalTokens == 450) } @Test func `claude counts different request ids separately`() throws { let env = try CostUsageTestEnvironment() defer { env.cleanup() } let day = try env.makeLocalNoon(year: 2025, month: 12, day: 20) let iso0 = env.isoString(for: day) let iso1 = env.isoString(for: day.addingTimeInterval(1)) let model = "claude-sonnet-4-20250514" let messageId = "msg_01ABC123" let entry1: [String: Any] = [ "type": "assistant", "timestamp": iso0, "requestId": "req_01AAA", "message": [ "id": messageId, "model": model, "usage": [ "input_tokens": 10, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "output_tokens": 5, ], ], ] let entry2: [String: Any] = [ "type": "assistant", "timestamp": iso1, "requestId": "req_01BBB", "message": [ "id": messageId, "model": model, "usage": [ "input_tokens": 20, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "output_tokens": 10, ], ], ] _ = try env.writeClaudeProjectFile( relativePath: "project-a/session-a.jsonl", contents: env.jsonl([entry1, entry2])) var options = CostUsageScanner.Options( codexSessionsRoot: nil, claudeProjectsRoots: [env.claudeProjectsRoot], cacheRoot: env.cacheRoot) options.refreshMinIntervalSeconds = 0 let report = CostUsageScanner.loadDailyReport( provider: .claude, since: day, until: day, now: day, options: options) #expect(report.data.count == 1) #expect(report.data[0].inputTokens == 30) #expect(report.data[0].outputTokens == 15) #expect(report.data[0].totalTokens == 45) } } struct CostUsageTestEnvironment { let root: URL let cacheRoot: URL let codexHomeRoot: URL let codexSessionsRoot: URL let codexArchivedSessionsRoot: URL let claudeProjectsRoot: URL let piSessionsRoot: URL init() throws { let root = FileManager.default.temporaryDirectory.appendingPathComponent( "codexbar-cost-usage-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) self.root = root self.cacheRoot = root.appendingPathComponent("cache", isDirectory: true) self.codexHomeRoot = root.appendingPathComponent("codex-home", isDirectory: true) self.codexSessionsRoot = self.codexHomeRoot.appendingPathComponent("sessions", isDirectory: true) self.codexArchivedSessionsRoot = self.codexHomeRoot .appendingPathComponent("archived_sessions", isDirectory: true) self.claudeProjectsRoot = root.appendingPathComponent("claude-projects", isDirectory: true) self.piSessionsRoot = root.appendingPathComponent("pi-sessions", isDirectory: true) try FileManager.default.createDirectory(at: self.cacheRoot, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: self.codexSessionsRoot, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: self.codexArchivedSessionsRoot, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: self.claudeProjectsRoot, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: self.piSessionsRoot, withIntermediateDirectories: true) } func cleanup() { try? FileManager.default.removeItem(at: self.root) } func makeLocalNoon(year: Int, month: Int, day: Int) throws -> Date { var comps = DateComponents() comps.calendar = Calendar.current comps.timeZone = TimeZone.current comps.year = year comps.month = month comps.day = day comps.hour = 12 comps.minute = 0 comps.second = 0 guard let date = comps.date else { throw NSError(domain: "CostUsageTestEnvironment", code: 1) } return date } func isoString(for date: Date) -> String { let fmt = ISO8601DateFormatter() fmt.formatOptions = [.withInternetDateTime] return fmt.string(from: date) } func writeCodexSessionFile(day: Date, filename: String, contents: String) throws -> URL { let comps = Calendar.current.dateComponents([.year, .month, .day], from: day) let y = String(format: "%04d", comps.year ?? 1970) let m = String(format: "%02d", comps.month ?? 1) let d = String(format: "%02d", comps.day ?? 1) let dir = self.codexSessionsRoot .appendingPathComponent(y, isDirectory: true) .appendingPathComponent(m, isDirectory: true) .appendingPathComponent(d, isDirectory: true) try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) let url = dir.appendingPathComponent(filename, isDirectory: false) try contents.write(to: url, atomically: true, encoding: .utf8) return url } func writeClaudeProjectFile(relativePath: String, contents: String) throws -> URL { let url = self.claudeProjectsRoot.appendingPathComponent(relativePath, isDirectory: false) try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) try contents.write(to: url, atomically: true, encoding: .utf8) return url } func writeClaudeDesktopLocalAgentFile(relativePath: String, contents: String) throws -> URL { let localAgentRoot = self.root .appendingPathComponent("Library", isDirectory: true) .appendingPathComponent("Application Support", isDirectory: true) .appendingPathComponent("Claude", isDirectory: true) .appendingPathComponent("local-agent-mode-sessions", isDirectory: true) .appendingPathComponent("workspace-id", isDirectory: true) .appendingPathComponent("session-id", isDirectory: true) .appendingPathComponent("local_agent", isDirectory: true) let url = localAgentRoot.appendingPathComponent(relativePath, isDirectory: false) try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) try contents.write(to: url, atomically: true, encoding: .utf8) return url } func writeClaudeDesktopLocalAgentProjectFile(relativePath: String, contents: String) throws -> URL { try self.writeClaudeDesktopLocalAgentFile( relativePath: ".claude/projects/\(relativePath)", contents: contents) } func writeClaudeDesktopCodeSessionProjectFile(relativePath: String, contents: String) throws -> URL { let projectsRoot = self.root .appendingPathComponent("Library", isDirectory: true) .appendingPathComponent("Application Support", isDirectory: true) .appendingPathComponent("Claude", isDirectory: true) .appendingPathComponent("claude-code-sessions", isDirectory: true) .appendingPathComponent("account-id", isDirectory: true) .appendingPathComponent("org-id", isDirectory: true) .appendingPathComponent(".claude", isDirectory: true) .appendingPathComponent("projects", isDirectory: true) let url = projectsRoot.appendingPathComponent(relativePath, isDirectory: false) try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) try contents.write(to: url, atomically: true, encoding: .utf8) return url } func writeClaudeDesktopSharedProjectFile(relativePath: String, contents: String) throws -> URL { let projectsRoot = self.root .appendingPathComponent(".claude", isDirectory: true) .appendingPathComponent("projects", isDirectory: true) let url = projectsRoot.appendingPathComponent(relativePath, isDirectory: false) try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) try contents.write(to: url, atomically: true, encoding: .utf8) return url } func writeNestedClaudeDesktopLocalAgentProjectFile(relativePath: String, contents: String) throws -> URL { let projectsRoot = self.root .appendingPathComponent("Library", isDirectory: true) .appendingPathComponent("Application Support", isDirectory: true) .appendingPathComponent("Claude", isDirectory: true) .appendingPathComponent("local-agent-mode-sessions", isDirectory: true) .appendingPathComponent("workspace-id", isDirectory: true) .appendingPathComponent("session-id", isDirectory: true) .appendingPathComponent("agent", isDirectory: true) .appendingPathComponent("local_agent", isDirectory: true) .appendingPathComponent(".claude", isDirectory: true) .appendingPathComponent("projects", isDirectory: true) let url = projectsRoot.appendingPathComponent(relativePath, isDirectory: false) try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) try contents.write(to: url, atomically: true, encoding: .utf8) return url } func writeCodexArchivedSessionFile(filename: String, contents: String) throws -> URL { let url = self.codexArchivedSessionsRoot.appendingPathComponent(filename, isDirectory: false) try contents.write(to: url, atomically: true, encoding: .utf8) return url } func writePiSessionFile(relativePath: String, contents: String) throws -> URL { let url = self.piSessionsRoot.appendingPathComponent(relativePath, isDirectory: false) try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) try contents.write(to: url, atomically: true, encoding: .utf8) return url } func jsonl(_ objects: [Any]) throws -> String { let lines = try objects.map { obj in let data = try JSONSerialization.data(withJSONObject: obj) guard let text = String(bytes: data, encoding: .utf8) else { throw NSError(domain: "CostUsageTestEnvironment", code: 2) } return text } return lines.joined(separator: "\n") + "\n" } }