use std::collections::HashSet;
use std::fs;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use crate::civil::{day_of, Day};
#[allow(dead_code)]
pub struct ToolCall<'a> {
pub id: &'a str,
pub tool: &'a str,
pub command: &'a str,
pub day: Day,
pub session: &'a str,
pub cwd: &'a str,
pub sidechain: bool,
}
#[derive(Debug, PartialEq, Eq)]
pub enum Discovery {
Explicit,
ConfigDirEnv,
HomeDefault,
}
pub struct Roots {
pub dirs: Vec<PathBuf>,
#[allow(dead_code)]
pub how: Discovery,
}
#[derive(Debug)]
pub enum ScanError {
NoHome,
Missing(PathBuf),
}
impl ScanError {
pub fn explain(&self) -> String {
match self {
ScanError::NoHome => {
"cannot find your home directory: set CLAUDE_CONFIG_DIR or pass --transcripts"
.to_string()
}
ScanError::Missing(p) => format!(
"no transcripts at {} — pass --transcripts <dir> if they live elsewhere",
p.display()
),
}
}
}
pub fn roots(explicit: &[PathBuf]) -> Result<Roots, ScanError> {
if !explicit.is_empty() {
for d in explicit {
if !d.is_dir() {
return Err(ScanError::Missing(d.clone()));
}
}
return Ok(Roots {
dirs: explicit.to_vec(),
how: Discovery::Explicit,
});
}
let how = if std::env::var_os("CLAUDE_CONFIG_DIR").is_some() {
Discovery::ConfigDirEnv
} else {
Discovery::HomeDefault
};
let base = crate::settings::config_dir().ok_or(ScanError::NoHome)?;
let projects = base.join("projects");
if !projects.is_dir() {
return Err(ScanError::Missing(projects));
}
Ok(Roots {
dirs: vec![projects],
how,
})
}
pub struct Scan<'a> {
pub roots: &'a Roots,
pub tool: &'static str,
pub since: Option<Day>,
}
#[derive(Default, Debug)]
pub struct Stats {
pub files: usize,
pub bytes: u64,
pub lines: u64,
pub parsed: u64,
pub calls: u64,
pub duplicates: u64,
pub bad_lines: u64,
pub oversized: u64,
pub truncated_tail: usize,
pub events: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Outcome {
Ok,
Failed,
Unknown,
}
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub struct Record {
pub id: String,
pub tool: String,
pub command: String,
pub day: Day,
pub session: String,
pub cwd: String,
pub model: String,
pub sidechain: bool,
pub outcome: Outcome,
}
const MAX_LINE: usize = 1024 * 1024;
pub fn files(roots: &Roots) -> Vec<PathBuf> {
let mut out = Vec::new();
for dir in &roots.dirs {
collect(dir, &mut out);
}
out.sort();
out
}
fn collect(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = fs::read_dir(dir) else {
return;
};
for e in entries.flatten() {
let path = e.path();
match e.file_type() {
Ok(t) if t.is_dir() => collect(&path, out),
Ok(t) if t.is_file() && path.extension().is_some_and(|x| x == "jsonl") => {
out.push(path);
}
_ => {}
}
}
}
pub fn for_each_call<F>(scan: &Scan, mut f: F) -> Result<Stats, ScanError>
where
F: FnMut(&ToolCall),
{
let mut stats = Stats::default();
let mut seen: HashSet<String> = HashSet::new();
let needle_tool = format!("\"{}\"", scan.tool);
for path in files(scan.roots) {
let Ok(file) = fs::File::open(&path) else {
continue;
};
stats.files += 1;
let mut reader = BufReader::new(file);
let mut line = String::new();
loop {
line.clear();
let read = match reader.read_line(&mut line) {
Ok(0) => break,
Ok(n) => n,
Err(_) => break,
};
stats.bytes += read as u64;
stats.lines += 1;
let complete = line.ends_with('\n');
let raw = line.trim_end();
if raw.is_empty() {
continue;
}
if raw.len() > MAX_LINE {
stats.oversized += 1;
continue;
}
if !raw.contains("\"tool_use\"") || !raw.contains(&needle_tool) {
continue;
}
let Ok(entry) = serde_json::from_str::<serde_json::Value>(raw) else {
if !complete {
stats.truncated_tail += 1;
} else {
stats.bad_lines += 1;
}
continue;
};
stats.parsed += 1;
let Some(day) = entry
.get("timestamp")
.and_then(|t| t.as_str())
.and_then(day_of)
else {
continue;
};
if scan.since.is_some_and(|s| day < s) {
continue;
}
let session = entry
.get("sessionId")
.and_then(|v| v.as_str())
.unwrap_or("");
let cwd = entry.get("cwd").and_then(|v| v.as_str()).unwrap_or("");
let sidechain = entry
.get("isSidechain")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let Some(blocks) = entry
.get("message")
.and_then(|m| m.get("content"))
.and_then(|c| c.as_array())
else {
continue;
};
for block in blocks {
if block.get("type").and_then(|t| t.as_str()) != Some("tool_use") {
continue;
}
let Some(tool) = block.get("name").and_then(|n| n.as_str()) else {
continue;
};
if tool != scan.tool {
continue;
}
let Some(id) = block.get("id").and_then(|i| i.as_str()) else {
continue;
};
if !seen.insert(id.to_string()) {
stats.duplicates += 1;
continue;
}
let command = block
.get("input")
.and_then(|i| i.get("command"))
.and_then(|c| c.as_str())
.unwrap_or("");
stats.calls += 1;
f(&ToolCall {
id,
tool,
command,
day,
session,
cwd,
sidechain,
});
}
}
}
Ok(stats)
}
pub fn for_each_transcript<F>(scan: &Scan, mut f: F) -> Result<Stats, ScanError>
where
F: FnMut(&[Record]),
{
let mut stats = Stats::default();
let mut seen: HashSet<String> = HashSet::new();
for path in files(scan.roots) {
let Ok(file) = fs::File::open(&path) else {
continue;
};
stats.files += 1;
let mut reader = BufReader::new(file);
let mut line = String::new();
let mut records: Vec<Record> = Vec::new();
let mut at: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
loop {
line.clear();
let read = match reader.read_line(&mut line) {
Ok(0) => break,
Ok(n) => n,
Err(_) => break,
};
stats.bytes += read as u64;
stats.lines += 1;
let complete = line.ends_with('\n');
let raw = line.trim_end();
if raw.is_empty() {
continue;
}
if raw.len() > MAX_LINE {
stats.oversized += 1;
continue;
}
if let Some(id) = tool_use_id(raw) {
if let Some(&i) = at.get(id) {
records[i].outcome = if errored(raw) {
Outcome::Failed
} else {
Outcome::Ok
};
}
continue;
}
if !raw.contains("\"tool_use\"") {
continue;
}
let Ok(entry) = serde_json::from_str::<serde_json::Value>(raw) else {
if !complete {
stats.truncated_tail += 1;
} else {
stats.bad_lines += 1;
}
continue;
};
stats.parsed += 1;
let Some(day) = entry
.get("timestamp")
.and_then(|t| t.as_str())
.and_then(day_of)
else {
continue;
};
if scan.since.is_some_and(|s| day < s) {
continue;
}
let session = entry
.get("sessionId")
.and_then(|v| v.as_str())
.unwrap_or("");
let cwd = entry.get("cwd").and_then(|v| v.as_str()).unwrap_or("");
let sidechain = entry
.get("isSidechain")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let message = entry.get("message");
let model = message
.and_then(|m| m.get("model"))
.and_then(|v| v.as_str())
.unwrap_or("");
let Some(blocks) = message
.and_then(|m| m.get("content"))
.and_then(|c| c.as_array())
else {
continue;
};
for block in blocks {
if block.get("type").and_then(|t| t.as_str()) != Some("tool_use") {
continue;
}
let Some(tool) = block.get("name").and_then(|n| n.as_str()) else {
continue;
};
let Some(id) = block.get("id").and_then(|i| i.as_str()) else {
continue;
};
if !seen.insert(id.to_string()) {
stats.duplicates += 1;
continue;
}
let command = block
.get("input")
.and_then(|i| i.get("command"))
.and_then(|c| c.as_str())
.unwrap_or("");
stats.events += 1;
if tool == scan.tool {
stats.calls += 1;
}
at.insert(id.to_string(), records.len());
records.push(Record {
id: id.to_string(),
tool: tool.to_string(),
command: command.to_string(),
day,
session: session.to_string(),
cwd: cwd.to_string(),
model: model.to_string(),
sidechain,
outcome: Outcome::Unknown,
});
}
}
if !records.is_empty() {
f(&records);
}
}
Ok(stats)
}
fn tool_use_id(raw: &str) -> Option<&str> {
const KEY: &str = "\"tool_use_id\":\"";
let start = raw.find(KEY)? + KEY.len();
let rest = &raw[start..];
let end = rest.find('"')?;
Some(&rest[..end])
}
fn errored(raw: &str) -> bool {
const KEY: &str = "\"is_error\":";
match raw.rfind(KEY) {
Some(i) => raw[i + KEY.len()..].trim_start().starts_with("true"),
None => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_result_line_is_recognised_by_its_tool_use_id() {
let line = r#"{"message":{"content":[{"tool_use_id":"toolu_01ab","type":"tool_result","content":"ok","is_error":false}]}}"#;
assert_eq!(tool_use_id(line), Some("toolu_01ab"));
assert!(!errored(line));
}
#[test]
fn the_error_flag_is_read_from_the_end_of_the_line() {
let line = r#"{"message":{"content":[{"tool_use_id":"t1","type":"tool_result","content":"Exit code 2","is_error":true}]}}"#;
assert!(errored(line));
}
#[test]
fn output_that_quotes_the_flag_does_not_decide_the_outcome() {
let line = r#"{"message":{"content":[{"tool_use_id":"t1","type":"tool_result","content":"grepped: \"is_error\":true","is_error":false}]}}"#;
assert!(!errored(line), "the last flag on the line is the real one");
}
#[test]
fn a_call_line_is_not_a_result_line() {
let line = r#"{"message":{"content":[{"type":"tool_use","id":"t1","name":"Bash","input":{"command":"ls"}}]}}"#;
assert_eq!(tool_use_id(line), None);
}
}