1use std::collections::BTreeSet;
4use std::env;
5use std::fmt;
6use std::fs::{self, File};
7use std::io::{BufRead, BufReader};
8use std::path::{Path, PathBuf};
9
10use chrono::{DateTime, LocalResult, TimeZone, Utc};
11use serde::{Deserialize, Serialize};
12use serde_json::Value;
13use thiserror::Error;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
16#[serde(rename_all = "kebab-case")]
17pub enum ProviderId {
18 ClaudeCode,
19 Codex,
20 Cursor,
21}
22
23impl fmt::Display for ProviderId {
24 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
25 let value = match self {
26 Self::ClaudeCode => "claude-code",
27 Self::Codex => "codex",
28 Self::Cursor => "cursor",
29 };
30 f.write_str(value)
31 }
32}
33
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct HostEnv {
36 pub home_dir: PathBuf,
37 pub windows_home_dir: Option<PathBuf>,
38 pub is_wsl: bool,
39}
40
41impl HostEnv {
42 pub fn new(home_dir: PathBuf, windows_home_dir: Option<PathBuf>, is_wsl: bool) -> Self {
43 Self {
44 home_dir,
45 windows_home_dir,
46 is_wsl,
47 }
48 }
49
50 pub fn detect() -> Self {
51 let home_dir = env::var_os("HOME")
52 .map(PathBuf::from)
53 .or_else(|| env::var_os("USERPROFILE").map(PathBuf::from))
54 .unwrap_or_else(|| PathBuf::from("."));
55 let is_wsl = fs::read_to_string("/proc/sys/kernel/osrelease")
56 .map(|value| {
57 let value = value.to_ascii_lowercase();
58 value.contains("microsoft") || value.contains("wsl")
59 })
60 .unwrap_or(false);
61 let windows_home_dir = detect_windows_home(is_wsl);
62
63 Self::new(home_dir, windows_home_dir, is_wsl)
64 }
65
66 pub fn claude_roots(&self) -> Vec<PathBuf> {
67 let mut roots = Vec::new();
68 roots.extend(claude_config_dir_roots());
69 roots.push(self.home_dir.join(".config").join("claude"));
70 roots.push(self.home_dir.join(".claude"));
71 if let Some(windows_home) = &self.windows_home_dir {
72 roots.push(windows_home.join(".config").join("claude"));
73 roots.push(windows_home.join(".claude"));
74 }
75 dedupe_paths(roots)
76 }
77
78 pub fn codex_roots(&self) -> Vec<PathBuf> {
79 let mut roots = vec![self.home_dir.join(".codex")];
80 if let Some(windows_home) = &self.windows_home_dir {
81 roots.push(windows_home.join(".codex"));
82 }
83 dedupe_paths(roots)
84 }
85}
86
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct DataLocation {
89 pub provider: ProviderId,
90 pub root: PathBuf,
91 pub files: Vec<PathBuf>,
92}
93
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
95#[serde(rename_all = "snake_case")]
96pub enum AccessPath {
97 Api,
98 Subscription,
99 Unknown,
100}
101
102#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
103pub struct UsageEvent {
104 pub tool: ProviderId,
105 pub model: String,
106 pub timestamp: DateTime<Utc>,
107 pub input_tokens: u64,
108 pub output_tokens: u64,
109 pub cache_read_tokens: u64,
110 pub cache_write_tokens: u64,
111 pub project: Option<String>,
112 pub access_path: AccessPath,
113}
114
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
116#[serde(rename_all = "kebab-case")]
117pub enum LimitKind {
118 FiveHour,
119 Weekly,
120}
121
122#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
123pub struct LimitWindow {
124 pub tool: ProviderId,
125 pub plan: Option<String>,
126 pub kind: LimitKind,
127 pub used_fraction: Option<f64>,
128 pub resets_at: Option<DateTime<Utc>>,
129 pub label: Option<String>,
130}
131
132#[derive(Debug, Error)]
133pub enum ProviderError {
134 #[error("{provider}: {message}")]
135 DataUnavailable {
136 provider: ProviderId,
137 message: String,
138 },
139
140 #[error("{provider}: failed to read {path}: {source}")]
141 Io {
142 provider: ProviderId,
143 path: PathBuf,
144 source: std::io::Error,
145 },
146}
147
148pub trait Provider: Send + Sync {
149 fn id(&self) -> ProviderId;
150
151 fn discover(&self, env: &HostEnv) -> Result<Option<DataLocation>, ProviderError>;
152
153 fn parse_usage(&self, loc: &DataLocation) -> Result<Vec<UsageEvent>, ProviderError>;
154
155 fn parse_limits(&self, loc: &DataLocation) -> Result<Vec<LimitWindow>, ProviderError>;
156}
157
158#[derive(Debug, Default)]
159pub struct ClaudeCodeProvider;
160
161impl Provider for ClaudeCodeProvider {
162 fn id(&self) -> ProviderId {
163 ProviderId::ClaudeCode
164 }
165
166 fn discover(&self, env: &HostEnv) -> Result<Option<DataLocation>, ProviderError> {
167 for root in env.claude_roots() {
168 let files = collect_jsonl_files(ProviderId::ClaudeCode, &root.join("projects"))?;
169 if !files.is_empty() {
170 return Ok(Some(DataLocation {
171 provider: ProviderId::ClaudeCode,
172 root,
173 files,
174 }));
175 }
176 }
177 Ok(None)
178 }
179
180 fn parse_usage(&self, loc: &DataLocation) -> Result<Vec<UsageEvent>, ProviderError> {
181 let access_path = claude_access_path(&loc.root);
182 let mut events = Vec::new();
183 for file in &loc.files {
184 for value in read_jsonl_values(ProviderId::ClaudeCode, file)? {
185 if let Some(event) = parse_claude_usage(&value, access_path) {
186 events.push(event);
187 }
188 }
189 }
190 Ok(events)
191 }
192
193 fn parse_limits(&self, _loc: &DataLocation) -> Result<Vec<LimitWindow>, ProviderError> {
194 Ok(vec![
195 unavailable_limit(ProviderId::ClaudeCode, LimitKind::FiveHour),
196 unavailable_limit(ProviderId::ClaudeCode, LimitKind::Weekly),
197 ])
198 }
199}
200
201#[derive(Debug, Default)]
202pub struct CodexProvider;
203
204impl Provider for CodexProvider {
205 fn id(&self) -> ProviderId {
206 ProviderId::Codex
207 }
208
209 fn discover(&self, env: &HostEnv) -> Result<Option<DataLocation>, ProviderError> {
210 for root in env.codex_roots() {
211 let files = collect_jsonl_files(ProviderId::Codex, &root.join("sessions"))?;
212 if !files.is_empty() {
213 return Ok(Some(DataLocation {
214 provider: ProviderId::Codex,
215 root,
216 files,
217 }));
218 }
219 }
220 Ok(None)
221 }
222
223 fn parse_usage(&self, loc: &DataLocation) -> Result<Vec<UsageEvent>, ProviderError> {
224 let has_subscription_limits = codex_has_rate_limits(loc)?;
225 let access_path = if has_subscription_limits {
226 AccessPath::Subscription
227 } else {
228 AccessPath::Unknown
229 };
230 let mut events = Vec::new();
231 for file in &loc.files {
232 events.extend(parse_codex_file(file, access_path)?.usage_events);
233 }
234 Ok(events)
235 }
236
237 fn parse_limits(&self, loc: &DataLocation) -> Result<Vec<LimitWindow>, ProviderError> {
238 let mut primary = None;
239 let mut secondary = None;
240 for file in &loc.files {
241 let parsed = parse_codex_file(file, AccessPath::Unknown)?;
242 primary = choose_limit(primary, parsed.primary_limit);
243 secondary = choose_limit(secondary, parsed.secondary_limit);
244 }
245 let limits = vec![
246 primary.unwrap_or_else(|| unavailable_limit(ProviderId::Codex, LimitKind::FiveHour)),
247 secondary.unwrap_or_else(|| unavailable_limit(ProviderId::Codex, LimitKind::Weekly)),
248 ];
249 Ok(limits)
250 }
251}
252
253#[derive(Debug, Default)]
254pub struct CursorProvider;
255
256impl Provider for CursorProvider {
257 fn id(&self) -> ProviderId {
258 ProviderId::Cursor
259 }
260
261 fn discover(&self, _env: &HostEnv) -> Result<Option<DataLocation>, ProviderError> {
262 Ok(None)
263 }
264
265 fn parse_usage(&self, loc: &DataLocation) -> Result<Vec<UsageEvent>, ProviderError> {
266 let mut events = Vec::new();
267 for file in &loc.files {
268 let contents = read_to_string(ProviderId::Cursor, file)?;
269 let value: Value = match serde_json::from_str(&contents) {
270 Ok(value) => value,
271 Err(_) => continue,
272 };
273 if let Some(items) = value.get("usage_events").and_then(Value::as_array) {
274 for item in items {
275 if let Some(event) = parse_cursor_usage(item) {
276 events.push(event);
277 }
278 }
279 }
280 }
281 Ok(events)
282 }
283
284 fn parse_limits(&self, _loc: &DataLocation) -> Result<Vec<LimitWindow>, ProviderError> {
285 Ok(vec![unavailable_limit(
286 ProviderId::Cursor,
287 LimitKind::Weekly,
288 )])
289 }
290}
291
292fn choose_limit(current: Option<LimitWindow>, next: Option<LimitWindow>) -> Option<LimitWindow> {
293 match (current, next) {
294 (None, value) => value,
295 (Some(_), Some(next)) if limit_has_data(&next) => Some(next),
296 (Some(current), Some(_)) => Some(current),
297 (Some(current), None) => Some(current),
298 }
299}
300
301fn limit_has_data(limit: &LimitWindow) -> bool {
302 limit.used_fraction.is_some() || limit.resets_at.is_some()
303}
304
305pub fn default_providers() -> Vec<Box<dyn Provider>> {
306 vec![
307 Box::new(ClaudeCodeProvider),
308 Box::new(CodexProvider),
309 Box::new(CursorProvider),
310 ]
311}
312
313fn detect_windows_home(is_wsl: bool) -> Option<PathBuf> {
314 if let Some(profile) = env::var_os("USERPROFILE") {
315 let path = windows_profile_to_wsl_path(&PathBuf::from(profile));
316 if path.is_some() {
317 return path;
318 }
319 }
320 if !is_wsl {
321 return None;
322 }
323 env::var_os("USER").map(|user| PathBuf::from("/mnt/c/Users").join(user))
324}
325
326fn windows_profile_to_wsl_path(path: &Path) -> Option<PathBuf> {
327 let raw = path.to_string_lossy();
328 let bytes = raw.as_bytes();
329 if bytes.len() < 3 || bytes[1] != b':' {
330 return None;
331 }
332 let drive = (bytes[0] as char).to_ascii_lowercase();
333 let rest = raw[2..].replace('\\', "/");
334 let rest = rest.trim_start_matches('/');
335 Some(PathBuf::from(format!("/mnt/{drive}/{rest}")))
336}
337
338fn claude_config_dir_roots() -> Vec<PathBuf> {
339 env::var_os("CLAUDE_CONFIG_DIR")
340 .map(|value| {
341 value
342 .to_string_lossy()
343 .split(',')
344 .map(str::trim)
345 .filter(|value| !value.is_empty())
346 .map(PathBuf::from)
347 .collect()
348 })
349 .unwrap_or_default()
350}
351
352fn dedupe_paths(paths: Vec<PathBuf>) -> Vec<PathBuf> {
353 let mut seen = BTreeSet::new();
354 let mut deduped = Vec::new();
355 for path in paths {
356 if seen.insert(path.clone()) {
357 deduped.push(path);
358 }
359 }
360 deduped
361}
362
363fn collect_jsonl_files(provider: ProviderId, root: &Path) -> Result<Vec<PathBuf>, ProviderError> {
364 let mut files = Vec::new();
365 if !root.exists() {
366 return Ok(files);
367 }
368 collect_jsonl_files_inner(provider, root, &mut files)?;
369 files.sort();
370 Ok(files)
371}
372
373fn collect_jsonl_files_inner(
374 provider: ProviderId,
375 root: &Path,
376 files: &mut Vec<PathBuf>,
377) -> Result<(), ProviderError> {
378 let entries = fs::read_dir(root).map_err(|source| ProviderError::Io {
379 provider,
380 path: root.to_path_buf(),
381 source,
382 })?;
383 for entry in entries {
384 let entry = entry.map_err(|source| ProviderError::Io {
385 provider,
386 path: root.to_path_buf(),
387 source,
388 })?;
389 let path = entry.path();
390 let file_type = entry.file_type().map_err(|source| ProviderError::Io {
391 provider,
392 path: path.clone(),
393 source,
394 })?;
395 if file_type.is_dir() {
396 collect_jsonl_files_inner(provider, &path, files)?;
397 } else if file_type.is_file() && path.extension().is_some_and(|ext| ext == "jsonl") {
398 files.push(path);
399 }
400 }
401 Ok(())
402}
403
404fn read_jsonl_values(provider: ProviderId, path: &Path) -> Result<Vec<Value>, ProviderError> {
405 let file = File::open(path).map_err(|source| ProviderError::Io {
406 provider,
407 path: path.to_path_buf(),
408 source,
409 })?;
410 let reader = BufReader::new(file);
411 let mut values = Vec::new();
412 for line in reader.lines() {
413 let line = line.map_err(|source| ProviderError::Io {
414 provider,
415 path: path.to_path_buf(),
416 source,
417 })?;
418 if line.trim().is_empty() {
419 continue;
420 }
421 if let Ok(value) = serde_json::from_str::<Value>(&line) {
422 values.push(value);
423 }
424 }
425 Ok(values)
426}
427
428fn read_to_string(provider: ProviderId, path: &Path) -> Result<String, ProviderError> {
429 fs::read_to_string(path).map_err(|source| ProviderError::Io {
430 provider,
431 path: path.to_path_buf(),
432 source,
433 })
434}
435
436fn parse_claude_usage(value: &Value, access_path: AccessPath) -> Option<UsageEvent> {
437 if value.get("type").and_then(Value::as_str) != Some("assistant") {
438 return None;
439 }
440 if value.pointer("/message/role").and_then(Value::as_str) != Some("assistant") {
441 return None;
442 }
443 let usage = value.pointer("/message/usage")?;
444 let model = value.pointer("/message/model").and_then(Value::as_str)?;
445 let timestamp = parse_rfc3339(value.get("timestamp").and_then(Value::as_str)?)?;
446 let event = UsageEvent {
447 tool: ProviderId::ClaudeCode,
448 model: model.to_string(),
449 timestamp,
450 input_tokens: number_u64(usage.get("input_tokens")),
451 output_tokens: number_u64(usage.get("output_tokens")),
452 cache_read_tokens: number_u64(usage.get("cache_read_input_tokens")),
453 cache_write_tokens: number_u64(usage.get("cache_creation_input_tokens")),
454 project: value
455 .get("cwd")
456 .and_then(Value::as_str)
457 .map(ToString::to_string),
458 access_path,
459 };
460 has_any_tokens(&event).then_some(event)
461}
462
463fn claude_access_path(root: &Path) -> AccessPath {
464 if env::var_os("ANTHROPIC_API_KEY").is_some() {
465 return AccessPath::Api;
466 }
467 if root.join(".credentials.json").exists() || root.join("credentials.json").exists() {
468 return AccessPath::Subscription;
469 }
470 AccessPath::Unknown
471}
472
473#[derive(Debug, Default)]
474struct ParsedCodexFile {
475 usage_events: Vec<UsageEvent>,
476 primary_limit: Option<LimitWindow>,
477 secondary_limit: Option<LimitWindow>,
478}
479
480fn parse_codex_file(
481 path: &Path,
482 access_path: AccessPath,
483) -> Result<ParsedCodexFile, ProviderError> {
484 let mut parsed = ParsedCodexFile::default();
485 let mut current_model = None;
486 let mut current_cwd = None;
487
488 for value in read_jsonl_values(ProviderId::Codex, path)? {
489 update_codex_context(&value, &mut current_model, &mut current_cwd);
490 if let Some((primary, secondary)) = parse_codex_limits(&value) {
491 parsed.primary_limit = choose_limit(parsed.primary_limit, Some(primary));
492 parsed.secondary_limit = choose_limit(parsed.secondary_limit, Some(secondary));
493 }
494 if let Some(event) = parse_codex_usage(&value, access_path, ¤t_model, ¤t_cwd) {
495 parsed.usage_events.push(event);
496 }
497 }
498
499 Ok(parsed)
500}
501
502fn update_codex_context(
503 value: &Value,
504 current_model: &mut Option<String>,
505 current_cwd: &mut Option<String>,
506) {
507 if let Some(model) = value
508 .pointer("/payload/collaboration_mode/settings/model")
509 .and_then(Value::as_str)
510 .filter(|value| !value.is_empty())
511 {
512 *current_model = Some(model.to_string());
513 } else if let Some(model) = value
514 .pointer("/payload/model")
515 .and_then(Value::as_str)
516 .filter(|value| !value.is_empty())
517 {
518 *current_model = Some(model.to_string());
519 }
520
521 if let Some(cwd) = value
522 .pointer("/payload/cwd")
523 .and_then(Value::as_str)
524 .filter(|value| !value.is_empty())
525 {
526 *current_cwd = Some(cwd.to_string());
527 }
528}
529
530fn parse_codex_usage(
531 value: &Value,
532 access_path: AccessPath,
533 current_model: &Option<String>,
534 current_cwd: &Option<String>,
535) -> Option<UsageEvent> {
536 let usage = value.pointer("/payload/info/last_token_usage")?;
537 let timestamp = value
538 .get("timestamp")
539 .and_then(Value::as_str)
540 .and_then(parse_rfc3339)
541 .or_else(|| {
542 value
543 .pointer("/payload/timestamp")
544 .and_then(Value::as_str)
545 .and_then(parse_rfc3339)
546 })?;
547 let event = UsageEvent {
548 tool: ProviderId::Codex,
549 model: current_model
550 .clone()
551 .unwrap_or_else(|| "unknown".to_string()),
552 timestamp,
553 input_tokens: number_u64(usage.get("input_tokens")),
554 output_tokens: number_u64(usage.get("output_tokens")),
555 cache_read_tokens: number_u64(usage.get("cached_input_tokens")),
556 cache_write_tokens: 0,
557 project: current_cwd.clone(),
558 access_path,
559 };
560 has_any_tokens(&event).then_some(event)
561}
562
563fn parse_codex_limits(value: &Value) -> Option<(LimitWindow, LimitWindow)> {
564 let rate_limits = value.pointer("/payload/rate_limits")?;
565 let primary = parse_codex_limit(
566 rate_limits.get("primary"),
567 LimitKind::FiveHour,
568 rate_limits.get("plan_type").and_then(Value::as_str),
569 )?;
570 let secondary = parse_codex_limit(
571 rate_limits.get("secondary"),
572 LimitKind::Weekly,
573 rate_limits.get("plan_type").and_then(Value::as_str),
574 )?;
575 Some((primary, secondary))
576}
577
578fn parse_codex_limit(
579 value: Option<&Value>,
580 kind: LimitKind,
581 plan: Option<&str>,
582) -> Option<LimitWindow> {
583 let value = value?;
584 let used_fraction = value
585 .get("used_percent")
586 .and_then(Value::as_f64)
587 .map(|pct| pct / 100.0);
588 let resets_at = value
589 .get("resets_at")
590 .and_then(Value::as_i64)
591 .and_then(epoch_seconds);
592 Some(LimitWindow {
593 tool: ProviderId::Codex,
594 plan: plan.map(ToString::to_string),
595 kind,
596 used_fraction,
597 resets_at,
598 label: None,
599 })
600}
601
602fn codex_has_rate_limits(loc: &DataLocation) -> Result<bool, ProviderError> {
603 for file in &loc.files {
604 for value in read_jsonl_values(ProviderId::Codex, file)? {
605 if value.pointer("/payload/rate_limits").is_some() {
606 return Ok(true);
607 }
608 }
609 }
610 Ok(false)
611}
612
613fn parse_cursor_usage(value: &Value) -> Option<UsageEvent> {
614 let timestamp = parse_rfc3339(value.get("timestamp").and_then(Value::as_str)?)?;
615 let event = UsageEvent {
616 tool: ProviderId::Cursor,
617 model: value.get("model").and_then(Value::as_str)?.to_string(),
618 timestamp,
619 input_tokens: number_u64(value.get("input_tokens")),
620 output_tokens: number_u64(value.get("output_tokens")),
621 cache_read_tokens: number_u64(value.get("cache_read_tokens")),
622 cache_write_tokens: number_u64(value.get("cache_write_tokens")),
623 project: value
624 .get("project")
625 .and_then(Value::as_str)
626 .map(ToString::to_string),
627 access_path: AccessPath::Unknown,
628 };
629 has_any_tokens(&event).then_some(event)
630}
631
632fn unavailable_limit(provider: ProviderId, kind: LimitKind) -> LimitWindow {
633 LimitWindow {
634 tool: provider,
635 plan: None,
636 kind,
637 used_fraction: None,
638 resets_at: None,
639 label: Some("unavailable".to_string()),
640 }
641}
642
643fn parse_rfc3339(value: &str) -> Option<DateTime<Utc>> {
644 DateTime::parse_from_rfc3339(value)
645 .ok()
646 .map(|value| value.with_timezone(&Utc))
647}
648
649fn epoch_seconds(value: i64) -> Option<DateTime<Utc>> {
650 match Utc.timestamp_opt(value, 0) {
651 LocalResult::Single(value) => Some(value),
652 LocalResult::Ambiguous(_, _) | LocalResult::None => None,
653 }
654}
655
656fn number_u64(value: Option<&Value>) -> u64 {
657 value.and_then(Value::as_u64).unwrap_or(0)
658}
659
660fn has_any_tokens(event: &UsageEvent) -> bool {
661 event.input_tokens > 0
662 || event.output_tokens > 0
663 || event.cache_read_tokens > 0
664 || event.cache_write_tokens > 0
665}
666
667#[cfg(test)]
668mod tests {
669 use super::*;
670
671 fn fixture_path(parts: &[&str]) -> PathBuf {
672 let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
673 path.push("..");
674 path.push("..");
675 path.push("fixtures");
676 for part in parts {
677 path.push(part);
678 }
679 path
680 }
681
682 #[test]
683 fn provider_ids_match_documented_values() {
684 assert_eq!(ProviderId::ClaudeCode.to_string(), "claude-code");
685 assert_eq!(ProviderId::Codex.to_string(), "codex");
686 assert_eq!(ProviderId::Cursor.to_string(), "cursor");
687 }
688
689 #[test]
690 fn wsl_roots_include_linux_and_windows_candidates() {
691 let env = HostEnv::new(
692 PathBuf::from("/home/example"),
693 Some(PathBuf::from("/mnt/c/Users/example")),
694 true,
695 );
696 let claude_roots = env.claude_roots();
697 let codex_roots = env.codex_roots();
698
699 assert!(claude_roots.contains(&PathBuf::from("/home/example/.config/claude")));
700 assert!(claude_roots.contains(&PathBuf::from("/mnt/c/Users/example/.claude")));
701 assert!(codex_roots.contains(&PathBuf::from("/home/example/.codex")));
702 assert!(codex_roots.contains(&PathBuf::from("/mnt/c/Users/example/.codex")));
703 }
704
705 #[test]
706 fn claude_fixture_parses_usage_and_unavailable_limits() {
707 let provider = ClaudeCodeProvider;
708 let loc = DataLocation {
709 provider: ProviderId::ClaudeCode,
710 root: fixture_path(&["claude-code"]),
711 files: vec![fixture_path(&["claude-code", "project-transcript.jsonl"])],
712 };
713
714 let usage = match provider.parse_usage(&loc) {
715 Ok(value) => value,
716 Err(err) => panic!("claude fixture should parse: {err}"),
717 };
718 let limits = match provider.parse_limits(&loc) {
719 Ok(value) => value,
720 Err(err) => panic!("claude limits should parse: {err}"),
721 };
722
723 assert_eq!(usage.len(), 1);
724 assert_eq!(usage[0].model, "claude-sonnet-example");
725 assert_eq!(usage[0].input_tokens, 10);
726 assert_eq!(usage[0].output_tokens, 20);
727 assert_eq!(usage[0].cache_read_tokens, 30);
728 assert_eq!(usage[0].cache_write_tokens, 40);
729 assert_eq!(limits.len(), 2);
730 assert!(limits.iter().all(|limit| limit.used_fraction.is_none()));
731 assert!(limits.iter().all(|limit| limit.resets_at.is_none()));
732 }
733
734 #[test]
735 fn codex_fixture_parses_usage_and_limits() {
736 let provider = CodexProvider;
737 let loc = DataLocation {
738 provider: ProviderId::Codex,
739 root: fixture_path(&["codex"]),
740 files: vec![fixture_path(&["codex", "rollout.jsonl"])],
741 };
742
743 let usage = match provider.parse_usage(&loc) {
744 Ok(value) => value,
745 Err(err) => panic!("codex fixture should parse: {err}"),
746 };
747 let limits = match provider.parse_limits(&loc) {
748 Ok(value) => value,
749 Err(err) => panic!("codex limits should parse: {err}"),
750 };
751
752 assert_eq!(usage.len(), 1);
753 assert_eq!(usage[0].model, "example-model");
754 assert_eq!(usage[0].access_path, AccessPath::Subscription);
755 assert_eq!(usage[0].cache_read_tokens, 300);
756 assert_eq!(limits.len(), 2);
757 assert!(
758 limits
759 .iter()
760 .any(|limit| limit.kind == LimitKind::FiveHour
761 && close_to(limit.used_fraction, 0.425))
762 );
763 assert!(limits
764 .iter()
765 .any(|limit| limit.kind == LimitKind::Weekly && close_to(limit.used_fraction, 0.1825)));
766 }
767
768 #[test]
769 fn cursor_fixture_parses_partial_usage_only() {
770 let provider = CursorProvider;
771 let loc = DataLocation {
772 provider: ProviderId::Cursor,
773 root: fixture_path(&["cursor"]),
774 files: vec![fixture_path(&["cursor", "local-partial.json"])],
775 };
776
777 let usage = match provider.parse_usage(&loc) {
778 Ok(value) => value,
779 Err(err) => panic!("cursor fixture should parse: {err}"),
780 };
781 let limits = match provider.parse_limits(&loc) {
782 Ok(value) => value,
783 Err(err) => panic!("cursor limits should parse: {err}"),
784 };
785
786 assert_eq!(usage.len(), 1);
787 assert_eq!(usage[0].access_path, AccessPath::Unknown);
788 assert_eq!(limits.len(), 1);
789 assert_eq!(limits[0].label.as_deref(), Some("unavailable"));
790 }
791
792 fn close_to(value: Option<f64>, expected: f64) -> bool {
793 value
794 .map(|value| (value - expected).abs() < 0.000_001)
795 .unwrap_or(false)
796 }
797}