use std::fs;
use std::io::{BufRead, BufReader, Seek, SeekFrom};
use std::path::{Path, PathBuf};
use serde_json::Value;
use crate::accounting::classifier;
use crate::accounting::pricing;
use crate::global_db::GlobalDb;
use crate::types::CostTurn;
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "lowercase")]
pub enum CoverageState {
Complete,
Partial,
Absent,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct SourceCoverage {
pub agent: &'static str,
pub state: CoverageState,
pub sessions: u64,
}
pub(crate) fn find_session_files(home: &Path) -> (Vec<PathBuf>, bool) {
let projects_dir = home.join(".claude").join("projects");
match fs::metadata(&projects_dir) {
Ok(metadata) if metadata.is_dir() => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return (Vec::new(), false);
}
Ok(_) | Err(_) => return (Vec::new(), true),
}
let mut files = Vec::new();
let had_errors = collect_jsonl_files(&projects_dir, &mut files, 0);
files.sort();
(files, had_errors)
}
fn collect_jsonl_files(dir: &Path, out: &mut Vec<PathBuf>, depth: u8) -> bool {
if depth > 5 {
return false;
}
let Ok(entries) = fs::read_dir(dir) else {
return true;
};
let mut had_errors = false;
for entry in entries {
let Ok(entry) = entry else {
had_errors = true;
continue;
};
let path = entry.path();
if path.is_dir() {
had_errors |= collect_jsonl_files(&path, out, depth + 1);
} else if path.extension().and_then(|e| e.to_str()) == Some("jsonl") {
out.push(path);
}
}
had_errors
}
fn extract_path_parts(path: &Path) -> (String, String) {
let components: Vec<&str> = path
.components()
.filter_map(|c| c.as_os_str().to_str())
.collect();
let projects_idx = components.iter().position(|c| *c == "projects");
let project_hash = projects_idx
.and_then(|i| components.get(i + 1))
.unwrap_or(&"unknown")
.to_string();
let session_id = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string();
(project_hash, session_id)
}
fn parse_line(line: &str, project_hash: &str, session_id: &str) -> Option<CostTurn> {
let v: Value = serde_json::from_str(line).ok()?;
if v.get("type")?.as_str()? != "assistant" {
return None;
}
let msg = v.get("message")?;
let message_id = msg.get("id")?.as_str()?;
let model = msg.get("model")?.as_str()?;
let usage = msg.get("usage")?;
let input_tokens = usage.get("input_tokens")?.as_u64().unwrap_or(0);
let output_tokens = usage.get("output_tokens")?.as_u64().unwrap_or(0);
let cache_write_tokens = usage
.get("cache_creation_input_tokens")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0);
let cache_read_tokens = usage
.get("cache_read_input_tokens")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0);
let timestamp = parse_timestamp(v.get("timestamp")?.as_str()?)?;
let content = msg.get("content").and_then(|c| c.as_array());
let mut tool_names_vec: Vec<String> = Vec::new();
let mut bash_commands: Vec<String> = Vec::new();
if let Some(blocks) = content {
for block in blocks {
if block.get("type").and_then(|t| t.as_str()) == Some("tool_use") {
if let Some(name) = block.get("name").and_then(|n| n.as_str()) {
tool_names_vec.push(name.to_string());
if name == "Bash" {
if let Some(cmd) = block
.get("input")
.and_then(|i| i.get("command"))
.and_then(|c| c.as_str())
{
bash_commands.push(cmd.to_string());
}
}
}
}
}
}
let tool_refs: Vec<&str> = tool_names_vec
.iter()
.map(std::string::String::as_str)
.collect();
let bash_refs: Vec<&str> = bash_commands
.iter()
.map(std::string::String::as_str)
.collect();
let category = classifier::classify(&tool_refs, &bash_refs);
let cost_usd = pricing::cost_of_turn(
model,
input_tokens,
output_tokens,
cache_write_tokens,
cache_read_tokens,
);
Some(CostTurn {
message_id: message_id.to_string(),
project_hash: project_hash.to_string(),
session_id: session_id.to_string(),
model: model.to_string(),
timestamp,
input_tokens,
output_tokens,
cache_write_tokens,
cache_read_tokens,
cost_usd,
category: category.as_str().to_string(),
tool_names: tool_names_vec.join(","),
agent: "claude".to_string(),
credits: None,
})
}
pub(crate) fn parse_timestamp(ts: &str) -> Option<u64> {
if ts.len() < 19 {
return None;
}
let year: i64 = ts.get(0..4)?.parse().ok()?;
let month: u64 = ts.get(5..7)?.parse().ok()?;
let day: u64 = ts.get(8..10)?.parse().ok()?;
let hour: u64 = ts.get(11..13)?.parse().ok()?;
let min: u64 = ts.get(14..16)?.parse().ok()?;
let sec: u64 = ts.get(17..19)?.parse().ok()?;
let bytes = ts.as_bytes();
if bytes.get(4) != Some(&b'-')
|| bytes.get(7) != Some(&b'-')
|| bytes.get(10) != Some(&b'T')
|| bytes.get(13) != Some(&b':')
|| bytes.get(16) != Some(&b':')
|| year < 1970
|| !(1..=12).contains(&month)
|| hour >= 24
|| min >= 60
|| sec >= 60
{
return None;
}
let month_days = [0u64, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
let max_day = month_days[month as usize] + u64::from(month == 2 && is_leap(year));
if day == 0 || day > max_day {
return None;
}
let mut days: i64 = 0;
for y in 1970..year {
days += if is_leap(y) { 366 } else { 365 };
}
for m in 1..month {
days += month_days[m as usize] as i64;
}
if month > 2 && is_leap(year) {
days += 1;
}
days += (day as i64) - 1;
Some((days as u64) * 86400 + hour * 3600 + min * 60 + sec)
}
fn is_leap(y: i64) -> bool {
y % 4 == 0 && (y % 100 != 0 || y % 400 == 0)
}
pub struct IngestStats {
pub turns_inserted: u64,
pub turns_updated: u64,
pub cost_usd: f64,
pub tokens_consumed: u64,
pub coverage: Vec<SourceCoverage>,
}
impl IngestStats {
pub fn empty() -> Self {
Self {
turns_inserted: 0,
turns_updated: 0,
cost_usd: 0.0,
tokens_consumed: 0,
coverage: Vec::new(),
}
}
}
async fn ingest_claude_from_home(gdb: &GlobalDb, home: &Path) -> IngestStats {
let (files, mut had_errors) = find_session_files(home);
let claude_sessions = files.len() as u64;
let mut total_inserted = 0u64;
let mut total_cost = 0.0f64;
let mut total_tokens = 0u64;
for file_path in &files {
let path_str = file_path.to_string_lossy().to_string();
let Ok(meta) = fs::metadata(file_path) else {
had_errors = true;
continue;
};
let mtime = meta
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map_or(0, |d| d.as_secs());
let (prev_offset, prev_mtime) = gdb.get_parse_offset(&path_str).await.unwrap_or((0, 0));
if mtime == prev_mtime && prev_offset > 0 {
continue;
}
let seek_to = if mtime == prev_mtime || (prev_mtime > 0 && mtime > prev_mtime) {
prev_offset
} else {
0
};
let (project_hash, session_id) = extract_path_parts(file_path);
let Ok(f) = fs::File::open(file_path) else {
had_errors = true;
continue;
};
let mut reader = BufReader::new(f);
if seek_to > 0 && reader.seek(SeekFrom::Start(seek_to)).is_err() {
had_errors = true;
continue;
}
let mut line = String::new();
let mut current_offset = seek_to;
let mut read_failed = false;
loop {
line.clear();
match reader.read_line(&mut line) {
Ok(0) => break,
Err(_) => {
had_errors = true;
read_failed = true;
break;
}
Ok(n) => {
current_offset += n as u64;
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if let Some(turn) = parse_line(trimmed, &project_hash, &session_id) {
let turn_cost = turn.cost_usd;
let turn_tokens = turn.input_tokens + turn.output_tokens;
if gdb.insert_turn(&turn).await {
total_inserted += 1;
total_cost += turn_cost;
total_tokens += turn_tokens;
}
}
}
}
}
if !read_failed {
gdb.set_parse_offset(&path_str, current_offset, mtime).await;
}
}
let claude_state = if had_errors {
CoverageState::Partial
} else if files.is_empty() {
CoverageState::Absent
} else {
CoverageState::Complete
};
IngestStats {
turns_inserted: total_inserted,
turns_updated: 0,
cost_usd: total_cost,
tokens_consumed: total_tokens,
coverage: vec![SourceCoverage {
agent: "claude",
state: claude_state,
sessions: claude_sessions,
}],
}
}
pub(crate) async fn ingest_from_home(gdb: &GlobalDb, home: &Path) -> IngestStats {
let mut stats = ingest_claude_from_home(gdb, home).await;
let droid_root = home.join(".factory").join("sessions");
let droid = crate::accounting::droid::ingest_dir(gdb, &droid_root).await;
stats.turns_updated = droid.rows_changed;
stats.coverage.push(droid.coverage);
stats
}
pub async fn ingest_claude_only(gdb: &GlobalDb) -> IngestStats {
let Some(home) = crate::agents::home_dir() else {
return IngestStats::empty();
};
ingest_claude_from_home(gdb, &home).await
}
pub async fn ingest(gdb: &GlobalDb) -> IngestStats {
let Some(home) = crate::agents::home_dir() else {
return IngestStats::empty();
};
ingest_from_home(gdb, &home).await
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use tempfile::TempDir;
async fn open_test_db(tmp: &TempDir) -> GlobalDb {
let db_path = tmp.path().join(".tokensave").join("global.db");
std::fs::create_dir_all(db_path.parent().unwrap()).unwrap();
GlobalDb::open_at(&db_path).await.unwrap()
}
#[tokio::test]
async fn claude_only_ingest_has_no_droid_coverage() {
let home_tmp = TempDir::new().unwrap();
let db_tmp = TempDir::new().unwrap();
let gdb = open_test_db(&db_tmp).await;
let stats = ingest_claude_from_home(&gdb, home_tmp.path()).await;
assert_eq!(
stats.turns_updated, 0,
"claude-only must not update Droid rows"
);
assert!(
!stats.coverage.iter().any(|c| c.agent == "droid"),
"claude-only must not include droid coverage"
);
assert_eq!(
stats.coverage.len(),
1,
"exactly one coverage entry (claude)"
);
assert_eq!(stats.coverage[0].agent, "claude");
}
#[test]
fn test_parse_timestamp() {
let ts = parse_timestamp("2026-01-01T00:00:00.000Z");
assert!(ts.is_some());
let epoch = ts.unwrap();
assert!(epoch > 1_700_000_000);
assert!(epoch < 1_800_000_000);
}
#[test]
fn invalid_timestamp_components_return_none() {
assert_eq!(parse_timestamp("2026-14-01T00:00:00Z"), None);
assert_eq!(parse_timestamp("2026-02-30T00:00:00Z"), None);
assert_eq!(parse_timestamp("2026-01-01T24:00:00Z"), None);
assert_eq!(parse_timestamp("2026-01-01T00:60:00Z"), None);
assert_eq!(parse_timestamp("1969-12-31T23:59:59Z"), None);
assert_eq!(parse_timestamp("2026/01/01 00:00:00Z"), None);
}
#[cfg(unix)]
#[tokio::test]
async fn claude_file_read_error_marks_coverage_partial() {
let home_tmp = TempDir::new().unwrap();
let db_tmp = TempDir::new().unwrap();
let projects = home_tmp.path().join(".claude/projects/project");
std::fs::create_dir_all(&projects).unwrap();
std::os::unix::fs::symlink(
projects.join("missing-target"),
projects.join("broken.jsonl"),
)
.unwrap();
let gdb = open_test_db(&db_tmp).await;
let stats = ingest_claude_from_home(&gdb, home_tmp.path()).await;
assert_eq!(stats.coverage[0].state, CoverageState::Partial);
assert_eq!(stats.coverage[0].sessions, 1);
}
#[test]
fn test_parse_timestamp_invalid() {
assert!(parse_timestamp("bad").is_none());
assert!(parse_timestamp("").is_none());
}
#[test]
fn test_extract_path_parts() {
let path =
PathBuf::from("/Users/test/.claude/projects/-Users-test-Code/abc123-session.jsonl");
let (project, session) = extract_path_parts(&path);
assert_eq!(project, "-Users-test-Code");
assert_eq!(session, "abc123-session");
}
#[test]
fn test_parse_line_assistant() {
let line = r#"{"type":"assistant","message":{"id":"msg_01abc","model":"claude-opus-4-6","role":"assistant","usage":{"input_tokens":1000,"output_tokens":200,"cache_creation_input_tokens":500,"cache_read_input_tokens":800},"content":[{"type":"tool_use","name":"Edit","input":{"file_path":"test.rs"}}]},"timestamp":"2026-04-14T10:00:00.000Z"}"#;
let turn = parse_line(line, "proj", "sess");
assert!(turn.is_some());
let t = turn.unwrap();
assert_eq!(t.message_id, "msg_01abc");
assert_eq!(t.model, "claude-opus-4-6");
assert_eq!(t.input_tokens, 1000);
assert_eq!(t.output_tokens, 200);
assert_eq!(t.cache_write_tokens, 500);
assert_eq!(t.cache_read_tokens, 800);
assert_eq!(t.category, "coding");
assert_eq!(t.tool_names, "Edit");
assert!(t.cost_usd > 0.0);
}
#[test]
fn test_parse_line_user_skipped() {
let line = r#"{"type":"user","message":{"content":"hello"},"timestamp":"2026-04-14T10:00:00.000Z"}"#;
assert!(parse_line(line, "proj", "sess").is_none());
}
#[test]
fn test_parse_line_malformed() {
assert!(parse_line("not json at all", "proj", "sess").is_none());
assert!(parse_line("{}", "proj", "sess").is_none());
}
}