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,
}
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() => {
if 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)
}