use std::collections::HashSet;
use std::path::Path;
use chrono::{DateTime, Utc};
use super::{ScanEntry, collect_jsonl_files, token_field};
pub(crate) fn scan_sessions(pi_dir: &Path, since: DateTime<Utc>) -> Vec<ScanEntry> {
let mut out = Vec::new();
let mut seen: HashSet<(String, i64)> = HashSet::new();
for path in collect_jsonl_files(pi_dir.join("agent").join("sessions"), |_| true) {
read_session_file(&path, since, &mut seen, &mut out);
}
out
}
fn read_session_file(
path: &Path,
since: DateTime<Utc>,
seen: &mut HashSet<(String, i64)>,
out: &mut Vec<ScanEntry>,
) {
let Ok(content) = std::fs::read_to_string(path) else {
return;
};
let mut lines = content.lines();
let Some(cwd) = lines
.next()
.and_then(|line| serde_json::from_str::<serde_json::Value>(line).ok())
.and_then(|value| parse_header_cwd(&value))
else {
return;
};
let mut models: Vec<(String, u64, Option<f64>)> = Vec::new();
for line in lines {
let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
continue;
};
let Some((model, tokens, cost, ts_millis)) = parse_assistant_message(&value, since) else {
continue;
};
if let Some(id) = value.get("id").and_then(serde_json::Value::as_str)
&& !seen.insert((id.to_owned(), ts_millis))
{
continue;
}
match models.iter_mut().find(|(m, ..)| m == model) {
Some(agg) => {
agg.1 += tokens;
if let Some(c) = cost {
agg.2 = Some(agg.2.unwrap_or(0.0) + c);
}
}
None => models.push((model.to_owned(), tokens, cost)),
}
}
for (model, tokens, cost_usd) in models {
out.push(ScanEntry {
cwd: cwd.clone(),
model: Some(model),
tokens,
cost_usd,
});
}
}
fn parse_header_cwd(value: &serde_json::Value) -> Option<String> {
if value.get("type").and_then(serde_json::Value::as_str) != Some("session") {
return None;
}
value
.get("cwd")
.and_then(serde_json::Value::as_str)
.map(str::to_owned)
}
fn parse_assistant_message(
value: &serde_json::Value,
since: DateTime<Utc>,
) -> Option<(&str, u64, Option<f64>, i64)> {
if value.get("type").and_then(serde_json::Value::as_str) != Some("message") {
return None;
}
let message = value.get("message")?;
if message.get("role").and_then(serde_json::Value::as_str) != Some("assistant") {
return None;
}
let ts_millis = message
.get("timestamp")
.and_then(serde_json::Value::as_i64)?;
let ts = DateTime::<Utc>::from_timestamp_millis(ts_millis)?;
if ts < since {
return None;
}
let model = message.get("model").and_then(serde_json::Value::as_str)?;
let usage = message.get("usage")?;
let tokens = token_field(usage, "input")
+ token_field(usage, "output")
+ token_field(usage, "cacheRead")
+ token_field(usage, "cacheWrite");
let cost = usage
.get("cost")
.and_then(|c| c.get("total"))
.and_then(serde_json::Value::as_f64);
Some((model, tokens, cost, ts_millis))
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeDelta;
use std::fs;
fn header_line(cwd: &str) -> String {
format!(
r#"{{"type":"session","version":3,"id":"0197-abc","timestamp":"2026-06-12T10:00:00.000Z","cwd":"{cwd}"}}"#
)
}
fn user_line() -> String {
r#"{"type":"message","id":"aaaa1111","parentId":null,"message":{"role":"user","content":"hi","timestamp":1765533600000}}"#.to_owned()
}
fn assistant_line(
id: &str,
model: &str,
ts: DateTime<Utc>,
input: u64,
output: u64,
cache_read: u64,
cache_write: u64,
cost_total: f64,
) -> String {
format!(
r#"{{"type":"message","id":"{id}","parentId":"aaaa1111","message":{{"role":"assistant","content":[{{"type":"text","text":"ok"}}],"api":"anthropic-messages","provider":"anthropic","model":"{model}","usage":{{"input":{input},"output":{output},"cacheRead":{cache_read},"cacheWrite":{cache_write},"totalTokens":{},"cost":{{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0,"total":{cost_total}}}}},"stopReason":"stop","timestamp":{}}}}}"#,
input + output + cache_read + cache_write,
ts.timestamp_millis(),
)
}
fn write_session(pi_dir: &Path, dir_name: &str, file_name: &str, content: &str) {
let d = pi_dir.join("agent").join("sessions").join(dir_name);
fs::create_dir_all(&d).unwrap();
fs::write(d.join(file_name), content).unwrap();
}
#[test]
fn test_scan_sessions_missing_dir() {
let tmp = tempfile::tempdir().unwrap();
assert!(scan_sessions(tmp.path(), Utc::now()).is_empty());
}
#[test]
fn test_scan_sessions_reads_assistant_usage_and_cost() {
let tmp = tempfile::tempdir().unwrap();
let now = Utc::now();
let content = format!(
"{}\n{}\n{}\n",
header_line("/repos/api"),
user_line(),
assistant_line("b1", "claude-sonnet-4-5", now, 1000, 200, 5000, 300, 0.0325),
);
write_session(tmp.path(), "--repos-api--", "0197-abc.jsonl", &content);
let entries = scan_sessions(tmp.path(), now - TimeDelta::hours(1));
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].cwd, "/repos/api");
assert_eq!(entries[0].model.as_deref(), Some("claude-sonnet-4-5"));
assert_eq!(entries[0].tokens, 6500);
assert!((entries[0].cost_usd.unwrap() - 0.0325).abs() < 1e-12);
}
#[test]
fn test_scan_sessions_filters_messages_by_since_and_folds_per_model() {
let tmp = tempfile::tempdir().unwrap();
let now = Utc::now();
let old = now - TimeDelta::days(10);
let content = format!(
"{}\n{}\n{}\n",
header_line("/repos/api"),
assistant_line("b1", "claude-sonnet-4-5", old, 100, 10, 0, 0, 0.001),
assistant_line("b2", "claude-sonnet-4-5", now, 200, 20, 0, 0, 0.002),
);
write_session(tmp.path(), "--repos-api--", "s.jsonl", &content);
let entries = scan_sessions(tmp.path(), now - TimeDelta::days(3));
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].tokens, 220);
assert!((entries[0].cost_usd.unwrap() - 0.002).abs() < 1e-12);
let all = scan_sessions(tmp.path(), DateTime::<Utc>::from_timestamp(0, 0).unwrap());
assert_eq!(all.len(), 1);
assert_eq!(all[0].tokens, 330);
assert!((all[0].cost_usd.unwrap() - 0.003).abs() < 1e-12);
}
#[test]
fn test_scan_sessions_multiple_models_per_file() {
let tmp = tempfile::tempdir().unwrap();
let now = Utc::now();
let content = format!(
"{}\n{}\n{}\n",
header_line("/repos/api"),
assistant_line("b1", "claude-sonnet-4-5", now, 100, 10, 0, 0, 0.001),
assistant_line("b2", "gemini-3-pro", now, 200, 20, 0, 0, 0.002),
);
write_session(tmp.path(), "--repos-api--", "s.jsonl", &content);
let entries = scan_sessions(tmp.path(), now - TimeDelta::hours(1));
assert_eq!(entries.len(), 2);
let models: Vec<Option<&str>> = entries.iter().map(|e| e.model.as_deref()).collect();
assert!(models.contains(&Some("claude-sonnet-4-5")));
assert!(models.contains(&Some("gemini-3-pro")));
}
#[test]
fn test_scan_sessions_dedupes_branched_history_across_files() {
let tmp = tempfile::tempdir().unwrap();
let now = Utc::now();
let shared = assistant_line("b1", "claude-sonnet-4-5", now, 1000, 100, 0, 0, 0.60);
let original = format!(
"{}\n{shared}\n{}\n",
header_line("/repos/api"),
assistant_line("b2", "claude-sonnet-4-5", now, 500, 50, 0, 0, 0.40),
);
let branched = format!(
"{}\n{shared}\n{}\n",
header_line("/repos/api"),
assistant_line("b3", "claude-sonnet-4-5", now, 200, 20, 0, 0, 0.10),
);
write_session(tmp.path(), "--repos-api--", "orig.jsonl", &original);
write_session(tmp.path(), "--repos-api--", "branch.jsonl", &branched);
let entries = scan_sessions(tmp.path(), now - TimeDelta::hours(1));
let tokens: u64 = entries.iter().map(|e| e.tokens).sum();
let cost: f64 = entries.iter().filter_map(|e| e.cost_usd).sum();
assert_eq!(tokens, 1870);
assert!((cost - 1.10).abs() < 1e-9);
}
#[test]
fn test_scan_sessions_missing_cost_counts_tokens() {
let tmp = tempfile::tempdir().unwrap();
let now = Utc::now();
let no_cost = format!(
r#"{{"type":"message","id":"cccc3333","parentId":null,"message":{{"role":"assistant","content":[],"model":"local-qwen","usage":{{"input":50,"output":5,"cacheRead":0,"cacheWrite":0,"totalTokens":55}},"stopReason":"stop","timestamp":{}}}}}"#,
now.timestamp_millis()
);
let content = format!("{}\n{no_cost}\n", header_line("/repos/api"));
write_session(tmp.path(), "--repos-api--", "s.jsonl", &content);
let entries = scan_sessions(tmp.path(), now - TimeDelta::hours(1));
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].tokens, 55);
assert!(entries[0].cost_usd.is_none());
}
#[test]
fn test_scan_sessions_skips_files_without_leading_header() {
let tmp = tempfile::tempdir().unwrap();
let now = Utc::now();
let content = format!(
"{}\n",
assistant_line("b1", "claude-sonnet-4-5", now, 100, 10, 0, 0, 0.001)
);
write_session(tmp.path(), "--repos-api--", "s.jsonl", &content);
assert!(scan_sessions(tmp.path(), now - TimeDelta::hours(1)).is_empty());
}
#[test]
fn test_scan_sessions_skips_invalid_lines_and_non_jsonl_files() {
let tmp = tempfile::tempdir().unwrap();
let now = Utc::now();
let content = format!(
"{}\nnot json\n{}\n{}\n",
header_line("/repos/api"),
r#"{"type":"message","id":"x","message":{"role":"toolResult","timestamp":1}}"#,
assistant_line("b1", "claude-sonnet-4-5", now, 100, 10, 0, 0, 0.001),
);
write_session(tmp.path(), "--repos-api--", "s.jsonl", &content);
write_session(tmp.path(), "--repos-api--", "notes.txt", &content);
let entries = scan_sessions(tmp.path(), now - TimeDelta::hours(1));
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].tokens, 110);
}
#[test]
fn test_parse_assistant_message_rejects_bad_records() {
let since = Utc::now() - TimeDelta::hours(1);
let ms = Utc::now().timestamp_millis();
let v: serde_json::Value = serde_json::json!({"type": "model_change"});
assert!(parse_assistant_message(&v, since).is_none());
let v = serde_json::json!({
"type": "message",
"message": {"role": "assistant", "usage": {"input": 1}, "timestamp": ms}
});
assert!(parse_assistant_message(&v, since).is_none());
let v = serde_json::json!({
"type": "message",
"message": {"role": "assistant", "model": "m", "timestamp": ms}
});
assert!(parse_assistant_message(&v, since).is_none());
let v = serde_json::json!({
"type": "message",
"message": {"role": "assistant", "model": "m", "usage": {"input": 1}}
});
assert!(parse_assistant_message(&v, since).is_none());
}
#[test]
fn test_parse_header_cwd_requires_session_type_and_cwd() {
assert!(parse_header_cwd(&serde_json::json!({"type": "message"})).is_none());
assert!(parse_header_cwd(&serde_json::json!({"type": "session"})).is_none());
assert_eq!(
parse_header_cwd(&serde_json::json!({"type": "session", "cwd": "/x"})),
Some("/x".to_owned())
);
}
}