Skip to main content

costroid_providers/
lib.rs

1//! Provider-facing interfaces and local parsers for AI-tool usage data.
2
3use 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, &current_model, &current_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}