use super::extract::{self, for_each_jsonl};
use super::{Costs, ModelBreakdown, Session, SessionData, Surface, Tokens};
use crate::config;
use crate::pricing::{self, Provider};
use crate::util;
use rayon::prelude::*;
use serde_json::Value;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
fn for_each_record(path: &Path, mut f: impl FnMut(&Value)) -> std::io::Result<()> {
if path.extension().is_some_and(|ext| ext == "jsonl") {
return for_each_jsonl(path, |item| match item.get("$set") {
Some(patch) => f(patch),
None => f(item),
});
}
let root: Value = serde_json::from_str(&std::fs::read_to_string(path)?)?;
f(&root);
if let Some(messages) = root.get("messages").and_then(Value::as_array) {
messages.iter().for_each(f);
}
Ok(())
}
fn started_from_stem(stem: &str) -> Option<String> {
let (date, clock) = stem.strip_prefix("session-")?.split_once('T')?;
let mut parts = clock.split('-');
let (hour, minute) = (parts.next()?, parts.next()?);
if date.len() != 10 || hour.len() != 2 || minute.len() != 2 {
return None;
}
Some(format!("{date}T{hour}:{minute}:00Z"))
}
fn chat_files() -> Vec<PathBuf> {
config::list_dir(&config::GEMINI_CHATS_ROOT)
.into_iter()
.map(|project| config::GEMINI_CHATS_ROOT.join(project).join("chats"))
.filter(|dir| config::dir_exists(dir))
.flat_map(|dir| {
config::list_dir(&dir)
.into_iter()
.filter(|name| {
name.starts_with("session-")
&& (name.ends_with(".json") || name.ends_with(".jsonl"))
})
.map(move |name| dir.join(name))
})
.collect()
}
fn project_root(chats_dir: &Path) -> String {
chats_dir
.parent()
.map(|project| project.join(".project_root"))
.and_then(|marker| std::fs::read_to_string(marker).ok())
.map(|text| text.trim().to_string())
.unwrap_or_default()
}
fn summarize(path: PathBuf) -> Option<Session> {
let stem = path.file_stem()?.to_string_lossy().into_owned();
let started = started_from_stem(&stem)?;
let mut session = Session::new(Provider::Gemini, stem);
session.surface = Surface::Cli;
session.harness = "Gemini".into();
session.started_at = started;
session.last_active = util::ms_to_rfc3339(config::file_mtime_ms(&path) as i64);
session.label_source = project_root(path.parent()?);
session.data_file = Some(path);
Some(session)
}
pub fn list_sessions() -> Vec<Session> {
if !config::dir_exists(&config::GEMINI_CHATS_ROOT) {
return Vec::new();
}
let mut sessions: Vec<_> = chat_files().into_par_iter().filter_map(summarize).collect();
sessions.sort_by(|a, b| b.started_at.cmp(&a.started_at));
sessions
}
fn token(tokens: &Value, key: &str) -> u64 {
tokens.get(key).and_then(Value::as_u64).unwrap_or(0)
}
fn turn_tokens(usage: &Value) -> Tokens {
let input_total = token(usage, "input");
let cached_input = token(usage, "cached").min(input_total);
Tokens {
input: input_total - cached_input,
input_total,
cached_input,
output: token(usage, "output"),
reasoning_output: token(usage, "thoughts"),
total: token(usage, "total"),
..Default::default()
}
}
fn add(target: &mut Tokens, turn: &Tokens) {
target.input += turn.input;
target.input_total += turn.input_total;
target.cached_input += turn.cached_input;
target.output += turn.output;
target.reasoning_output += turn.reasoning_output;
target.total += turn.total;
}
fn turn_costs(tokens: &Tokens, rates: &pricing::CodexPricing) -> Costs {
let input = util::token_cost(tokens.input, rates.input);
let cached_input = util::token_cost(tokens.cached_input, rates.cached_input);
let output = util::token_cost(tokens.output + tokens.reasoning_output, rates.output);
Costs {
input,
cached_input,
output,
total: input + cached_input + output,
..Default::default()
}
}
fn add_costs(target: &mut Costs, turn: &Costs) {
target.input += turn.input;
target.cached_input += turn.cached_input;
target.output += turn.output;
target.total += turn.total;
}
pub fn extract(path: &Path) -> SessionData {
let mut data = SessionData::default();
let mut breakdown: HashMap<String, (Tokens, Costs)> = HashMap::new();
let result = for_each_record(path, |item| {
if let Some(summary) = item.get("summary").and_then(Value::as_str) {
data.title = Some(summary.to_string());
}
if item.get("type").and_then(Value::as_str) != Some("gemini") {
return;
}
let model = item
.get("model")
.and_then(Value::as_str)
.unwrap_or("unknown")
.to_string();
let ts = item
.get("timestamp")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
if let Some(usage) = item.get("tokens") {
data.last_model = model.clone();
let tokens = turn_tokens(usage);
let costs = turn_costs(&tokens, &pricing::resolve_codex(&model));
add(&mut data.tokens, &tokens);
add_costs(&mut data.costs, &costs);
let (model_tokens, model_costs) = breakdown.entry(model.clone()).or_default();
add(model_tokens, &tokens);
add_costs(model_costs, &costs);
if let Some(dt) = util::parse_ts(&ts) {
*data
.costs_by_day
.entry(util::local_date_key(&dt))
.or_default()
.entry(model.clone())
.or_insert(0.0) += costs.total;
*data
.costs_by_hour
.entry(util::local_hour_key(&dt))
.or_default()
.entry(model.clone())
.or_insert(0.0) += costs.total;
}
}
for call in item
.get("toolCalls")
.and_then(Value::as_array)
.into_iter()
.flatten()
{
let name = call
.get("name")
.and_then(Value::as_str)
.unwrap_or("unknown");
let args = call.get("args").unwrap_or(&Value::Null);
let (short, full) = extract::tool_detail(name, args);
extract::push_tool_detail(
&mut data.metrics.tool_details,
name,
short,
full,
call.get("timestamp")
.and_then(Value::as_str)
.unwrap_or(&ts)
.to_string(),
call.get("id").and_then(Value::as_str).map(str::to_string),
None,
);
*data.metrics.tools.entry(name.to_string()).or_insert(0) += 1;
data.metrics.tool_count += 1;
if let Some(detail) = data
.metrics
.tool_details
.get_mut(name)
.and_then(|details| details.last_mut())
{
detail.failed = call.get("status").and_then(Value::as_str) == Some("error");
}
}
});
if let Err(err) = result {
data.error = Some(format!(
"Could not read Gemini chat {}: {err}",
path.display()
));
return data;
}
let mut models: Vec<_> = breakdown.into_iter().collect();
models.sort_by(|a, b| a.0.cmp(&b.0));
data.models = models.iter().map(|(model, _)| model.clone()).collect();
data.model_breakdown = models
.into_iter()
.map(|(model, (tokens, costs))| ModelBreakdown {
model,
total: costs.total,
tokens,
costs,
})
.collect();
data
}
pub fn delete(session: &Session) -> std::io::Result<()> {
let Some(path) = &session.data_file else {
return Ok(());
};
std::fs::remove_file(path)
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture(name: &str, body: &str) -> PathBuf {
let path = std::env::temp_dir().join(format!("cctop-gemini-{}-{name}", std::process::id()));
std::fs::write(&path, body).expect("write fixture");
path
}
#[test]
fn splits_cached_input_out_of_the_prompt_and_bills_thoughts_as_output() {
let path = fixture(
"usage.jsonl",
concat!(
"{\"sessionId\":\"79709c93\",\"startTime\":\"2026-05-14T17:34:01.387Z\",\"kind\":\"main\"}\n",
"{\"$set\":{\"summary\":\"Improving script latency\"}}\n",
"{\"type\":\"user\",\"timestamp\":\"2026-05-14T17:34:06.028Z\",\"content\":[{\"text\":\"go\"}]}\n",
"{\"type\":\"gemini\",\"timestamp\":\"2026-05-14T17:34:13.475Z\",\"model\":\"gemini-test\",",
"\"tokens\":{\"input\":8678,\"output\":133,\"cached\":8000,\"thoughts\":364,\"tool\":0,\"total\":9175},",
"\"toolCalls\":[{\"id\":\"read_file_1\",\"name\":\"read_file\",\"args\":{\"file_path\":\"README.md\"},\"status\":\"success\"},",
"{\"id\":\"replace_1\",\"name\":\"replace\",\"args\":{\"file_path\":\"src/main.rs\"},\"status\":\"error\"}]}\n",
),
);
let data = extract(&path);
std::fs::remove_file(&path).expect("remove fixture");
assert_eq!(data.title.as_deref(), Some("Improving script latency"));
assert_eq!(data.last_model, "gemini-test");
assert_eq!(data.tokens.input_total, 8678);
assert_eq!(data.tokens.cached_input, 8000);
assert_eq!(
data.tokens.input, 678,
"cached input is not billed at full rate"
);
assert_eq!(data.tokens.reasoning_output, 364);
assert_eq!(data.tokens.total, 9175);
assert_eq!(data.tokens.all_input(), 8678);
assert_eq!(data.metrics.tool_count, 2);
assert_eq!(data.metrics.tools.get("read_file"), Some(&1));
assert!(!data.metrics.tool_details["read_file"][0].failed);
assert!(
data.metrics.tool_details["replace"][0].failed,
"status=error must be flagged"
);
}
#[test]
fn reads_the_single_object_shape_identically() {
let path = fixture(
"whole.json",
concat!(
"{\"sessionId\":\"62ae72f3\",\"startTime\":\"2026-03-23T07:17:09.209Z\",",
"\"summary\":\"Clarify bundling\",\"messages\":[",
"{\"type\":\"gemini\",\"timestamp\":\"2026-03-23T07:17:13.148Z\",\"model\":\"gemini-test\",",
"\"tokens\":{\"input\":9679,\"output\":46,\"cached\":0,\"thoughts\":120,\"tool\":0,\"total\":9845},",
"\"toolCalls\":[{\"id\":\"read_file_2\",\"name\":\"read_file\",\"args\":{\"file_path\":\"docs/x.md\"},\"status\":\"success\"}]}]}",
),
);
let data = extract(&path);
std::fs::remove_file(&path).expect("remove fixture");
assert_eq!(data.title.as_deref(), Some("Clarify bundling"));
assert_eq!(data.tokens.total, 9845);
assert_eq!(data.tokens.input, 9679);
assert_eq!(data.tokens.cached_input, 0);
assert_eq!(data.metrics.tool_count, 1);
assert_eq!(data.model_breakdown.len(), 1);
}
#[test]
fn takes_the_start_time_from_the_chat_filename() {
assert_eq!(
started_from_stem("session-2026-05-14T17-34-79709c93").as_deref(),
Some("2026-05-14T17:34:00Z")
);
assert_eq!(started_from_stem("logs"), None);
assert_eq!(started_from_stem("session-nonsense"), None);
}
}