use serde_json::Value;
use std::{
collections::HashMap,
path::{Path, PathBuf},
};
use crate::{
apps::{fresh_input, normalize_model},
error::AppError,
io::{load, progress::Progress},
model::{AppKind, UsageEntry},
};
const MAX_DEPTH: usize = 4;
const GROK_LINE_NEEDLES: [&str; 1] = ["\"_x.ai/session/update\""];
pub fn collect(threads: Option<usize>) -> Result<Vec<UsageEntry>, AppError> {
let base = load::home_dir()?.join(".grok");
if !base.is_dir() {
return Ok(Vec::new());
}
collect_from_with(&base, threads)
}
#[cfg(test)]
pub fn collect_from(base: &Path) -> Result<Vec<UsageEntry>, AppError> {
collect_from_with(base, None)
}
fn collect_from_with(base: &Path, threads: Option<usize>) -> Result<Vec<UsageEntry>, AppError> {
let mut files: Vec<PathBuf> = Vec::new();
for root in ["sessions", "archived_sessions"] {
files.extend(load::discover_files(&base.join(root), "jsonl", MAX_DEPTH));
}
files.retain(|f| load::file_name_str(f) == "updates.jsonl");
let threads = threads.unwrap_or_else(|| load::auto_threads(files.len()));
let progress = Progress::start("grok", load::total_bytes(&files), files.len());
let per_file: Vec<HashMap<String, UsageEntry>> =
load::map_files(&files, threads, &progress, parse_updates);
progress.finish();
let mut candidates: HashMap<String, UsageEntry> = HashMap::new();
for map in per_file {
for (key, entry) in map {
candidates.insert(key, entry);
}
}
Ok(candidates.into_values().collect())
}
fn parse_updates(file: &Path, progress: &Progress) -> HashMap<String, UsageEntry> {
let mut candidates: HashMap<String, UsageEntry> = HashMap::new();
let session_id = file
.parent()
.and_then(|d| d.file_name())
.and_then(|n| n.to_str())
.unwrap_or("unknown");
let mut event_index = 0usize;
if let Err(e) = load::for_each_jsonl_progress(file, &GROK_LINE_NEEDLES, progress, |record| {
if load::str_get(&record, &["method"]) != Some("_x.ai/session/update") {
return true;
}
let Some(update) = record.get("params").and_then(|p| p.get("update")) else {
return true;
};
let kind = load::str_get(update, &["sessionUpdate"]);
if kind.is_some() && kind != Some("turn_completed") {
return true;
}
let Some(usage) = update.get("usage").filter(|u| u.is_object()) else {
return true;
};
let Some(created_at) = record.get("timestamp").and_then(load::timestamp_to_epoch) else {
return true;
};
let prompt_id = load::str_get(update, &["prompt_id"]).unwrap_or("");
let turn_key = if prompt_id.is_empty() {
format!("idx{event_index}")
} else {
prompt_id.to_string()
};
event_index += 1;
let event_partial = load::bool_get(usage, &["costIsPartial"]);
for (model, counters) in per_model(usage) {
let input = load::u64_get(counters, &["inputTokens"]);
let output = load::u64_get(counters, &["outputTokens"]);
let cached = load::u64_get(counters, &["cachedReadTokens"]);
let ticks = load::u64_get(counters, &["costUsdTicks"]);
let partial = event_partial || load::bool_get(counters, &["costIsPartial"]);
let self_cost = (ticks > 0 && !partial).then(|| ticks as f64 / 1e10);
if input == 0 && output == 0 && cached == 0 && self_cost.is_none() {
continue;
}
let input = fresh_input(input, cached, 0);
candidates.insert(
format!("{session_id}:{turn_key}:{model}"),
UsageEntry::new(
AppKind::Grok,
normalize_model(model),
Some(session_id.to_string()),
created_at,
input,
output,
cached,
0,
self_cost,
),
);
}
true
}) {
load::warn_file(&e);
progress.note_error();
}
candidates
}
fn per_model(usage: &Value) -> Vec<(&str, &Value)> {
match usage.get("modelUsage").and_then(Value::as_object) {
Some(map) if !map.is_empty() => {
let mut pairs: Vec<(&str, &Value)> = map.iter().map(|(k, v)| (k.as_str(), v)).collect();
pairs.sort_unstable_by_key(|(k, _)| *k);
pairs
}
_ => vec![("unknown", usage)],
}
}
#[cfg(test)]
#[path = "tests/grok_test.rs"]
mod tests;