use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use serde_json::Value;
use super::formatting::{number_u64, truncate_chars};
use super::types::{ParsedRun, TokenTotals, ToolCall};
pub(super) fn parse_run_file(file: &Path) -> Result<ParsedRun, String> {
let text = fs::read_to_string(file)
.map_err(|err| format!("failed to read {}: {err}", file.display()))?;
let mut parsed = ParsedRun::default();
for line in text.lines().filter(|line| !line.trim().is_empty()) {
let Ok(event) = serde_json::from_str::<Value>(line) else {
continue;
};
match event.get("type").and_then(Value::as_str) {
Some("system") if event.get("subtype").and_then(Value::as_str) == Some("init") => {
parsed.init_codegraph_tools =
event.get("tools").and_then(Value::as_array).map(|tools| {
tools
.iter()
.filter(|tool| {
tool.as_str().is_some_and(|name| name.contains("codegraph"))
})
.count()
});
}
Some("assistant") => {
if let Some(usage) = event.pointer("/message/usage") {
parsed.assistant_total_tokens += number_u64(usage.get("input_tokens"));
parsed.assistant_total_tokens += number_u64(usage.get("output_tokens"));
parsed.assistant_total_tokens +=
number_u64(usage.get("cache_read_input_tokens"));
parsed.assistant_total_tokens +=
number_u64(usage.get("cache_creation_input_tokens"));
}
for block in content_blocks(&event) {
if block.get("type").and_then(Value::as_str) != Some("tool_use") {
continue;
}
let name = block
.get("name")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
parsed.tool_calls.push(ToolCall {
id: block
.get("id")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
detail: tool_detail(block, &name),
name,
out_len: 0,
});
}
}
Some("user") => {
for block in content_blocks(&event) {
if block.get("type").and_then(Value::as_str) != Some("tool_result") {
continue;
}
let text = tool_result_text(block.get("content"));
if text.contains("No such tool available") {
parsed.raced = true;
}
let Some(id) = block.get("tool_use_id").and_then(Value::as_str) else {
continue;
};
if let Some(call) = parsed
.tool_calls
.iter_mut()
.find(|call| call.id.as_deref() == Some(id))
{
call.out_len = text.chars().count();
}
}
}
Some("result") => parsed.result = Some(event),
_ => {}
}
}
Ok(parsed)
}
fn content_blocks(event: &Value) -> Vec<&Value> {
event
.pointer("/message/content")
.and_then(Value::as_array)
.map(|blocks| blocks.iter().collect())
.unwrap_or_default()
}
fn tool_detail(block: &Value, name: &str) -> String {
let input = block.get("input").unwrap_or(&Value::Null);
if name == "Task" {
let subagent = input
.get("subagent_type")
.and_then(Value::as_str)
.unwrap_or("?");
let description = input
.get("description")
.and_then(Value::as_str)
.unwrap_or("");
return format!(
" [subagent_type={subagent}] {}",
truncate_chars(description, 40)
);
}
if name.contains("codegraph") {
let empty = Value::String(String::new());
let value = ["query", "task", "symbol"]
.iter()
.find_map(|key| input.get(*key))
.unwrap_or(&empty);
return format!(
" {}",
truncate_chars(&serde_json::to_string(value).unwrap_or_default(), 60)
);
}
if name == "Bash" {
return format!(
" {}",
truncate_chars(
input
.get("command")
.and_then(Value::as_str)
.unwrap_or_default(),
50
)
);
}
if name == "Read" {
let file = input
.get("file_path")
.and_then(Value::as_str)
.and_then(|path| Path::new(path).file_name())
.and_then(|name| name.to_str())
.unwrap_or("");
return format!(" {file}");
}
String::new()
}
fn tool_result_text(content: Option<&Value>) -> String {
match content {
Some(Value::String(text)) => text.clone(),
Some(Value::Array(items)) => items
.iter()
.filter_map(|item| item.get("text").and_then(Value::as_str))
.collect::<Vec<_>>()
.join(""),
Some(other) => other.to_string(),
None => String::new(),
}
}
pub(super) fn tally_tool_counts(file: &Path) -> Result<BTreeMap<String, usize>, String> {
let parsed = parse_run_file(file)?;
let mut counts = BTreeMap::new();
for call in parsed.tool_calls {
*counts.entry(call.name).or_default() += 1;
}
Ok(counts)
}
pub(super) fn sum_tokens(file: &Path) -> Result<TokenTotals, String> {
let text = fs::read_to_string(file)
.map_err(|err| format!("failed to read {}: {err}", file.display()))?;
let mut tokens = TokenTotals::default();
for line in text.lines().filter(|line| !line.trim().is_empty()) {
let Ok(event) = serde_json::from_str::<Value>(line) else {
continue;
};
let Some(usage) = event.pointer("/message/usage") else {
continue;
};
tokens.generated += number_u64(usage.get("output_tokens"));
tokens.fresh += number_u64(usage.get("input_tokens"))
+ number_u64(usage.get("cache_creation_input_tokens"));
tokens.cached += number_u64(usage.get("cache_read_input_tokens"));
}
Ok(tokens)
}
pub(super) fn jsonl_files(dir: &Path) -> Result<Vec<PathBuf>, String> {
let mut files = fs::read_dir(dir)
.map_err(|err| format!("failed to read {}: {err}", dir.display()))?
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("jsonl"))
.collect::<Vec<_>>();
files.sort();
Ok(files)
}