use serde::de::{
DeserializeSeed, Deserializer, IgnoredAny, MapAccess, SeqAccess, Visitor as DeVisitor,
};
use serde_json::Value;
use std::{collections::HashMap, fs, path::Path};
use crate::{
apps::{fresh_input, normalize_model, value_hash},
error::{AppError, io_err, json_err},
io::{load, progress::Progress},
model::{AppKind, UsageEntry},
};
const MAX_DEPTH: usize = 3;
pub fn collect(threads: Option<usize>) -> Result<Vec<UsageEntry>, AppError> {
let base = load::home_dir()?.join(".gemini").join("tmp");
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::new();
for file in load::discover_files(base, "json", MAX_DEPTH) {
let name = load::file_name_str(&file);
if name.starts_with("session-") && name.ends_with(".json") {
files.push(file);
}
}
let threads = threads.unwrap_or_else(|| load::auto_threads(files.len()));
let progress = Progress::start("gemini", load::total_bytes(&files), files.len());
let per_file: Vec<(String, HashMap<String, UsageEntry>)> = load::map_files(
&files,
threads,
&progress,
|file, progress| match stream_session(file, progress) {
Ok(staged) => staged,
Err(e) => {
load::warn_file(&e);
progress.note_error();
("unknown".to_string(), HashMap::new())
}
},
);
progress.finish();
let mut candidates: HashMap<String, UsageEntry> = HashMap::new();
for (sid, staged) in per_file {
for (msg_key, mut entry) in staged {
entry.session_id = Some(sid.clone());
candidates.insert(format!("{sid}:{msg_key}"), entry);
}
}
Ok(candidates.into_values().collect())
}
fn stream_session(
file: &Path,
progress: &Progress,
) -> Result<(String, HashMap<String, UsageEntry>), AppError> {
let f = fs::File::open(file).map_err(|e| io_err("open", file, e))?;
let reader = load::ProgressReader::new(std::io::BufReader::new(f), progress.clone());
let mut de = serde_json::Deserializer::from_reader(reader);
let mut session_id: Option<String> = None;
let mut staged: HashMap<String, UsageEntry> = HashMap::new();
de.deserialize_any(SessionVisitor {
session_id: &mut session_id,
staged: &mut staged,
})
.map_err(|e| json_err(file, e))?;
let sid = session_id.unwrap_or_else(|| "unknown".to_string());
Ok((sid, staged))
}
struct SessionVisitor<'a> {
session_id: &'a mut Option<String>,
staged: &'a mut HashMap<String, UsageEntry>,
}
impl<'de> DeVisitor<'de> for SessionVisitor<'_> {
type Value = ();
fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("a gemini session object")
}
fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<(), A::Error> {
while let Some(key) = map.next_key::<String>()? {
match key.as_str() {
"sessionId" => {
*self.session_id = map.next_value::<Value>()?.as_str().map(str::to_string);
}
"messages" => map.next_value_seed(MessagesSeed {
staged: self.staged,
})?,
_ => {
map.next_value::<IgnoredAny>()?;
}
}
}
Ok(())
}
}
struct MessagesSeed<'a> {
staged: &'a mut HashMap<String, UsageEntry>,
}
impl<'de> DeserializeSeed<'de> for MessagesSeed<'_> {
type Value = ();
fn deserialize<D: serde::Deserializer<'de>>(self, deserializer: D) -> Result<(), D::Error> {
deserializer.deserialize_seq(self)
}
}
impl<'de> DeVisitor<'de> for MessagesSeed<'_> {
type Value = ();
fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("an array of messages")
}
fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<(), A::Error> {
while let Some(msg) = seq.next_element::<Value>()? {
if let Some((msg_key, entry)) = parse_message(&msg) {
self.staged.insert(msg_key, entry); }
}
Ok(())
}
}
fn parse_message(msg: &Value) -> Option<(String, UsageEntry)> {
if load::str_get(msg, &["type"]) != Some("gemini") {
return None;
}
let input = load::u64_get(msg, &["tokens", "input"]);
let output = load::u64_get(msg, &["tokens", "output"]);
let cached = load::u64_get(msg, &["tokens", "cached"]);
let thoughts = load::u64_get(msg, &["tokens", "thoughts"]);
if input == 0 && output == 0 && cached == 0 && thoughts == 0 {
return None;
}
let input = fresh_input(input, cached, 0);
let msg_key = match load::str_get(msg, &["id"]).filter(|s| !s.is_empty()) {
Some(id) => id.to_string(),
None => format!("hash:{}", value_hash(msg)),
};
let model =
load::str_get(msg, &["model"]).map_or_else(|| "unknown".to_string(), normalize_model);
let created_at = msg
.get("timestamp")
.and_then(load::timestamp_to_epoch)
.unwrap_or_else(load::now_epoch);
Some((
msg_key,
UsageEntry::new(
AppKind::Gemini,
model,
None,
created_at,
input,
output + thoughts,
cached,
0,
None,
),
))
}
#[cfg(test)]
#[path = "tests/gemini_test.rs"]
mod tests;