use chrono::DateTime;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use super::activity::{classify, normalize_user_text, Activity};
pub struct ModelPricing {
pub input: f64,
pub output: f64,
pub cache_read: f64,
pub cache_write: f64,
}
pub fn pricing_for_model(model: &str) -> ModelPricing {
if model.contains("opus") {
ModelPricing {
input: 5.0,
output: 25.0,
cache_read: 0.5,
cache_write: 6.25,
}
} else if model.contains("haiku") {
ModelPricing {
input: 1.0,
output: 5.0,
cache_read: 0.1,
cache_write: 1.25,
}
} else {
ModelPricing {
input: 3.0,
output: 15.0,
cache_read: 0.3,
cache_write: 3.75,
}
}
}
pub fn context_window_for_model(model: &str) -> u64 {
if model.contains("[1m]") {
1_000_000
} else {
200_000
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UsageEntry {
pub session_id: String,
pub timestamp_ms: u64,
pub model: String,
pub input_tokens: u64,
pub output_tokens: u64,
pub cache_read_tokens: u64,
pub cache_write_tokens: u64,
pub tool_uses: Vec<String>,
pub text_chars: u32,
pub activity: Activity,
pub entrypoint: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HookExecution {
pub session_id: String,
pub timestamp_ms: u64,
pub hook_event: String,
pub hook_name: String,
pub command: String,
pub exit_code: i32,
pub duration_ms: u64,
pub tool_use_id: Option<String>,
}
#[derive(Default)]
pub struct ParsedJsonl {
pub entries: Vec<UsageEntry>,
pub hooks: Vec<HookExecution>,
}
#[derive(Debug, Clone, Serialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct SessionUsage {
pub model: String,
pub input_tokens: u64,
pub output_tokens: u64,
pub cache_read_tokens: u64,
pub cache_write_tokens: u64,
pub total_tokens: u64,
pub estimated_cost_usd: f64,
pub context_window_size: u64,
pub context_window_used_pct: f64,
pub current_context_tokens: u64,
pub total_context_sent: u64,
pub cache_hit_pct: f64,
pub message_count: u64,
pub session_id: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct JsonlRaw {
#[serde(rename = "type")]
entry_type: Option<String>,
timestamp: Option<String>,
message: Option<MessageData>,
attachment: Option<AttachmentData>,
subtype: Option<String>,
#[allow(dead_code)]
hook_count: Option<usize>,
hook_infos: Option<Vec<HookInfoEntry>>,
hook_errors: Option<Vec<serde_json::Value>>,
prevented_continuation: Option<bool>,
#[serde(rename = "toolUseID")]
tool_use_id: Option<String>,
entrypoint: Option<String>,
}
#[derive(Deserialize)]
struct MessageData {
id: Option<String>,
model: Option<String>,
usage: Option<UsageData>,
#[serde(default)]
content: Option<serde_json::Value>,
}
#[derive(Deserialize, Default)]
struct UsageData {
input_tokens: Option<u64>,
output_tokens: Option<u64>,
cache_read_input_tokens: Option<u64>,
cache_creation_input_tokens: Option<u64>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct HookInfoEntry {
command: Option<String>,
duration_ms: Option<u64>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct AttachmentData {
#[serde(rename = "type")]
attachment_type: Option<String>,
hook_name: Option<String>,
hook_event: Option<String>,
command: Option<String>,
exit_code: Option<i32>,
duration_ms: Option<u64>,
#[serde(rename = "toolUseID")]
tool_use_id: Option<String>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct ActiveSession {
session_id: String,
cwd: String,
started_at: u64,
}
use super::MAX_JSONL_BYTES;
pub fn encode_project_path(project_path: &str) -> String {
let normalized = project_path.replace('/', "\\");
let mut result = String::new();
let mut chars = normalized.chars().peekable();
while let Some(ch) = chars.next() {
if ch == ':' {
if chars.peek() == Some(&'\\') {
chars.next();
result.push_str("--");
} else {
result.push('-');
}
} else if ch == '\\' {
result.push('-');
} else {
result.push(ch);
}
}
while result.ends_with('-') {
result.pop();
}
result
}
struct AssistantContent {
tool_uses: Vec<String>,
bash_cmds: Vec<String>,
text_chars: u32,
}
fn extract_assistant_content(content: Option<&serde_json::Value>) -> AssistantContent {
let mut out = AssistantContent {
tool_uses: Vec::new(),
bash_cmds: Vec::new(),
text_chars: 0,
};
let arr = match content.and_then(|v| v.as_array()) {
Some(a) => a,
None => return out,
};
for block in arr {
let block_type = match block.get("type").and_then(|v| v.as_str()) {
Some(t) => t,
None => continue,
};
match block_type {
"tool_use" => {
if let Some(name) = block.get("name").and_then(|v| v.as_str()) {
if matches!(name, "Bash" | "BashTool" | "PowerShellTool") {
if let Some(cmd) = block
.get("input")
.and_then(|i| i.get("command"))
.and_then(|c| c.as_str())
{
out.bash_cmds.push(cmd.to_string());
}
}
out.tool_uses.push(name.to_string());
}
}
"text" => {
if let Some(text) = block.get("text").and_then(|v| v.as_str()) {
out.text_chars = out
.text_chars
.saturating_add(text.chars().count() as u32);
}
}
_ => {}
}
}
out
}
fn sanitize_hook_command_label(cmd: &str) -> String {
const MAX_LEN: usize = 64;
let first_line = cmd.lines().next().unwrap_or("").trim();
let collapsed: String = first_line.split_whitespace().collect::<Vec<_>>().join(" ");
if collapsed.chars().count() <= MAX_LEN {
return collapsed;
}
let truncated: String = collapsed.chars().take(MAX_LEN).collect();
format!("{truncated}…")
}
fn snake_to_pascal(s: &str) -> String {
s.split('_')
.filter(|w| !w.is_empty())
.map(|w| {
let mut c = w.chars();
match c.next() {
Some(first) => {
let mut out = first.to_uppercase().to_string();
out.extend(c);
out
}
None => String::new(),
}
})
.collect()
}
fn extract_user_text(content: &serde_json::Value) -> Option<String> {
match content {
serde_json::Value::String(s) => {
if s.is_empty() {
None
} else {
Some(s.clone())
}
}
serde_json::Value::Array(arr) => {
let mut parts: Vec<&str> = Vec::new();
for item in arr {
let t = item.get("type").and_then(|v| v.as_str()).unwrap_or("");
if t == "text" {
if let Some(text) = item.get("text").and_then(|v| v.as_str()) {
if !text.is_empty() {
parts.push(text);
}
}
}
}
if parts.is_empty() {
None
} else {
Some(parts.join(" "))
}
}
_ => None,
}
}
struct EntryAccumulator {
out_idx: usize,
bash_cmds: Vec<String>,
user_text_at_first: Option<String>,
}
pub fn parse_jsonl_entries(path: &Path) -> std::io::Result<ParsedJsonl> {
let meta = fs::metadata(path)?;
if meta.len() > MAX_JSONL_BYTES {
tracing::warn!(
path = %path.display(),
size = meta.len(),
"skipping JSONL larger than {}MB",
MAX_JSONL_BYTES / 1024 / 1024
);
return Ok(ParsedJsonl::default());
}
let file = fs::File::open(path)?;
let reader = BufReader::new(file);
let session_id = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
let mut accums: HashMap<String, EntryAccumulator> = HashMap::new();
let mut out: Vec<UsageEntry> = Vec::new();
let mut hooks: Vec<HookExecution> = Vec::new();
let mut last_user_text: Option<String> = None;
for line in reader.lines() {
let line = match line {
Ok(l) => l,
Err(_) => continue,
};
let line = line.trim();
if line.is_empty() {
continue;
}
let raw: JsonlRaw = match serde_json::from_str(line) {
Ok(r) => r,
Err(_) => continue,
};
let entry_type = raw.entry_type.as_deref();
if entry_type == Some("user") {
if let Some(msg) = raw.message.as_ref() {
if let Some(content) = msg.content.as_ref() {
if let Some(text) = extract_user_text(content) {
last_user_text = Some(normalize_user_text(&text));
}
}
}
continue;
}
if entry_type == Some("attachment") {
if let Some(att) = raw.attachment {
if att.attachment_type.as_deref() == Some("hook_success") {
let timestamp_ms = raw
.timestamp
.as_deref()
.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
.map(|dt| dt.timestamp_millis().max(0) as u64)
.unwrap_or(0);
hooks.push(HookExecution {
session_id: session_id.clone(),
timestamp_ms,
hook_event: att.hook_event.unwrap_or_default(),
hook_name: att.hook_name.unwrap_or_default(),
command: att.command.unwrap_or_default(),
exit_code: att.exit_code.unwrap_or(0),
duration_ms: att.duration_ms.unwrap_or(0),
tool_use_id: att.tool_use_id,
});
}
}
continue;
}
if entry_type == Some("system") {
if let Some(subtype) = raw.subtype.as_deref() {
if let Some(event_snake) = subtype.strip_suffix("_hook_summary") {
let hook_event = snake_to_pascal(event_snake);
let timestamp_ms = raw
.timestamp
.as_deref()
.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
.map(|dt| dt.timestamp_millis().max(0) as u64)
.unwrap_or(0);
let had_errors = raw
.hook_errors
.as_ref()
.is_some_and(|e| !e.is_empty());
let prevented = raw.prevented_continuation.unwrap_or(false);
if let Some(infos) = raw.hook_infos {
for info in infos {
let cmd = info.command.unwrap_or_default();
let hook_name =
format!("{}:{}", hook_event, sanitize_hook_command_label(&cmd));
hooks.push(HookExecution {
session_id: session_id.clone(),
timestamp_ms,
hook_event: hook_event.clone(),
hook_name,
command: cmd,
exit_code: if had_errors || prevented { 1 } else { 0 },
duration_ms: info.duration_ms.unwrap_or(0),
tool_use_id: raw.tool_use_id.clone(),
});
}
}
}
}
continue;
}
if entry_type != Some("assistant") {
continue;
}
let msg = match raw.message {
Some(m) => m,
None => continue,
};
let mid = match msg.id {
Some(id) => id,
None => continue,
};
let mut content_parsed = extract_assistant_content(msg.content.as_ref());
if let Some(acc) = accums.get_mut(&mid) {
let entry = &mut out[acc.out_idx];
entry
.tool_uses
.append(&mut content_parsed.tool_uses);
entry.text_chars = entry
.text_chars
.saturating_add(content_parsed.text_chars);
acc.bash_cmds.append(&mut content_parsed.bash_cmds);
continue;
}
let model = msg.model.unwrap_or_default();
if model == "<synthetic>" {
continue;
}
let usage = msg.usage.unwrap_or_default();
let timestamp_ms = raw
.timestamp
.as_deref()
.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
.map(|dt| dt.timestamp_millis().max(0) as u64)
.unwrap_or(0);
accums.insert(
mid.clone(),
EntryAccumulator {
out_idx: out.len(),
bash_cmds: std::mem::take(&mut content_parsed.bash_cmds),
user_text_at_first: last_user_text.clone(),
},
);
out.push(UsageEntry {
session_id: session_id.clone(),
timestamp_ms,
model,
input_tokens: usage.input_tokens.unwrap_or(0),
output_tokens: usage.output_tokens.unwrap_or(0),
cache_read_tokens: usage.cache_read_input_tokens.unwrap_or(0),
cache_write_tokens: usage.cache_creation_input_tokens.unwrap_or(0),
tool_uses: content_parsed.tool_uses,
text_chars: content_parsed.text_chars,
activity: Activity::General,
entrypoint: raw.entrypoint.clone(),
});
}
for acc in accums.values() {
let entry = &mut out[acc.out_idx];
let tool_refs: Vec<&str> = entry.tool_uses.iter().map(|s| s.as_str()).collect();
let bash_refs: Vec<&str> = acc.bash_cmds.iter().map(|s| s.as_str()).collect();
entry.activity = classify(
&tool_refs,
&bash_refs,
acc.user_text_at_first.as_deref(),
entry.text_chars,
);
}
Ok(ParsedJsonl {
entries: out,
hooks,
})
}
pub fn find_current_session_jsonl(
config_dir: &str,
project_path: &str,
spawned_at: SystemTime,
) -> Option<PathBuf> {
let config = Path::new(config_dir);
let encoded = encode_project_path(project_path);
let projects_dir = config.join("projects").join(&encoded);
if !projects_dir.exists() {
return None;
}
let sessions_dir = config.join("sessions");
if sessions_dir.exists() {
if let Some(session_id) = find_active_session_id(&sessions_dir, project_path, spawned_at) {
let jsonl_path = projects_dir.join(format!("{}.jsonl", session_id));
if jsonl_path.exists() {
return Some(jsonl_path);
}
}
}
let mut candidates: Vec<(PathBuf, SystemTime)> = Vec::new();
if let Ok(entries) = fs::read_dir(&projects_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
continue;
}
if let Ok(meta) = path.metadata() {
let created = meta.created().unwrap_or(SystemTime::UNIX_EPOCH);
if created >= spawned_at {
candidates.push((path, created));
}
}
}
}
candidates.sort_by(|a, b| a.1.cmp(&b.1));
candidates.into_iter().next().map(|(p, _)| p)
}
fn find_active_session_id(
sessions_dir: &Path,
project_path: &str,
spawned_at: SystemTime,
) -> Option<String> {
let spawned_ms = spawned_at
.duration_since(SystemTime::UNIX_EPOCH)
.ok()?
.as_millis() as u64;
let threshold_ms = spawned_ms.saturating_sub(10_000);
let normalized_project = normalize_path(project_path);
let mut best: Option<(String, u64)> = None;
if let Ok(entries) = fs::read_dir(sessions_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
if let Ok(content) = fs::read_to_string(&path) {
if let Ok(session) = serde_json::from_str::<ActiveSession>(&content) {
let session_cwd = normalize_path(&session.cwd);
if session_cwd == normalized_project && session.started_at >= threshold_ms {
if best.as_ref().is_none_or(|(_, t)| session.started_at < *t) {
best = Some((session.session_id, session.started_at));
}
}
}
}
}
}
best.map(|(id, _)| id)
}
fn normalize_path(p: &str) -> String {
p.replace('\\', "/")
.trim_end_matches('/')
.to_lowercase()
}
pub fn reduce_session(
entries: &[UsageEntry],
launched_model: &str,
session_id_hint: &str,
) -> Option<SessionUsage> {
if entries.is_empty() {
return None;
}
let mut input_tokens = 0u64;
let mut output_tokens = 0u64;
let mut cache_read = 0u64;
let mut cache_write = 0u64;
let mut model_counts: HashMap<&str, u64> = HashMap::new();
for e in entries {
input_tokens += e.input_tokens;
output_tokens += e.output_tokens;
cache_read += e.cache_read_tokens;
cache_write += e.cache_write_tokens;
if !e.model.is_empty() {
*model_counts.entry(e.model.as_str()).or_insert(0) += 1;
}
}
let primary_model = model_counts
.into_iter()
.max_by_key(|(_, c)| *c)
.map(|(m, _)| m.to_string())
.unwrap_or_else(|| {
entries
.last()
.map(|e| e.model.clone())
.unwrap_or_default()
});
let pricing = pricing_for_model(&primary_model);
let estimated_cost = (input_tokens as f64 * pricing.input
+ output_tokens as f64 * pricing.output
+ cache_read as f64 * pricing.cache_read
+ cache_write as f64 * pricing.cache_write)
/ 1_000_000.0;
let context_model = if launched_model.is_empty() {
&primary_model
} else {
launched_model
};
let context_window = context_window_for_model(context_model);
let last = entries.last().unwrap();
let last_context_used =
last.input_tokens + last.cache_read_tokens + last.cache_write_tokens;
let context_pct = if context_window > 0 {
(last_context_used as f64 / context_window as f64 * 100.0).min(100.0)
} else {
0.0
};
let total = input_tokens + output_tokens + cache_read + cache_write;
let total_context_sent = input_tokens + cache_read + cache_write;
let cache_denom = input_tokens + cache_read + cache_write;
let cache_hit_pct = if cache_denom > 0 {
(cache_read as f64 / cache_denom as f64) * 100.0
} else {
0.0
};
let session_id = entries
.first()
.map(|e| e.session_id.clone())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| session_id_hint.to_string());
Some(SessionUsage {
model: primary_model,
input_tokens,
output_tokens,
cache_read_tokens: cache_read,
cache_write_tokens: cache_write,
total_tokens: total,
estimated_cost_usd: estimated_cost,
context_window_size: context_window,
context_window_used_pct: context_pct,
current_context_tokens: last_context_used,
total_context_sent,
cache_hit_pct,
message_count: entries.len() as u64,
session_id,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::TempDir;
#[test]
fn test_encode_project_path_drive_root() {
assert_eq!(encode_project_path("S:\\Loom"), "S--Loom");
}
#[test]
fn test_encode_project_path_deep() {
assert_eq!(
encode_project_path("C:\\Users\\Seven\\Desktop\\SF\\project"),
"C--Users-Seven-Desktop-SF-project"
);
}
#[test]
fn test_encode_project_path_forward_slashes() {
assert_eq!(encode_project_path("S:/Loom"), "S--Loom");
}
#[test]
fn test_encode_project_path_trailing_slash() {
assert_eq!(encode_project_path("S:\\Loom\\"), "S--Loom");
}
#[test]
fn test_encode_project_path_macos() {
assert_eq!(
encode_project_path("/Users/seven/Loom"),
"-Users-seven-Loom"
);
}
#[test]
fn test_encode_project_path_linux() {
assert_eq!(
encode_project_path("/home/alice/projects/foo"),
"-home-alice-projects-foo"
);
}
#[test]
fn test_encode_project_path_unix_trailing_slash() {
assert_eq!(encode_project_path("/Users/seven/Loom/"), "-Users-seven-Loom");
}
#[test]
fn test_normalize_path() {
assert_eq!(normalize_path("S:\\Loom\\"), "s:/loom");
assert_eq!(normalize_path("S:/Loom"), "s:/loom");
assert_eq!(normalize_path("C:\\Users\\Seven\\"), "c:/users/seven");
}
fn write_jsonl(lines: &[&str]) -> (TempDir, PathBuf) {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("abc123.jsonl");
let mut f = fs::File::create(&path).unwrap();
for l in lines {
writeln!(f, "{}", l).unwrap();
}
(tmp, path)
}
fn assistant_line(id: &str, model: &str, ts: &str, inp: u64, out: u64, cr: u64, cw: u64) -> String {
format!(
r#"{{"type":"assistant","timestamp":"{ts}","message":{{"id":"{id}","model":"{model}","usage":{{"input_tokens":{inp},"output_tokens":{out},"cache_read_input_tokens":{cr},"cache_creation_input_tokens":{cw}}}}}}}"#
)
}
#[test]
fn parse_deduplicates_repeated_message_ids() {
let a1 = assistant_line("msg_01", "claude-opus-4-6", "2026-04-14T08:27:38.895Z", 15, 1463, 16397, 25661);
let a1_dup = a1.clone();
let a2 = assistant_line("msg_02", "claude-opus-4-6", "2026-04-14T08:34:37.200Z", 10, 401, 42058, 1668);
let a2_dup = a2.clone();
let a2_dup2 = a2.clone();
let (_tmp, path) = write_jsonl(&[&a1, &a1_dup, &a2, &a2_dup, &a2_dup2]);
let entries = parse_jsonl_entries(&path).unwrap().entries;
assert_eq!(entries.len(), 2, "should dedup by message.id");
assert_eq!(entries[0].input_tokens, 15);
assert_eq!(entries[0].output_tokens, 1463);
assert_eq!(entries[1].input_tokens, 10);
}
#[test]
fn parse_captures_entrypoint_from_assistant_row() {
let headless = r#"{"type":"assistant","timestamp":"2026-05-13T07:19:12.573Z","entrypoint":"sdk-cli","message":{"id":"msg_h1","model":"claude-opus-4-7","usage":{"input_tokens":5,"output_tokens":628,"cache_read_input_tokens":0,"cache_creation_input_tokens":14266}}}"#;
let interactive = r#"{"type":"assistant","timestamp":"2026-05-13T08:00:00.000Z","entrypoint":"cli","message":{"id":"msg_i1","model":"claude-opus-4-6","usage":{"input_tokens":10,"output_tokens":20,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}}"#;
let legacy = r#"{"type":"assistant","timestamp":"2026-05-13T09:00:00.000Z","message":{"id":"msg_l1","model":"claude-sonnet-4-6","usage":{"input_tokens":3,"output_tokens":4,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}}"#;
let (_tmp, path) = write_jsonl(&[headless, interactive, legacy]);
let entries = parse_jsonl_entries(&path).unwrap().entries;
assert_eq!(entries.len(), 3);
assert_eq!(entries[0].entrypoint.as_deref(), Some("sdk-cli"));
assert_eq!(entries[1].entrypoint.as_deref(), Some("cli"));
assert_eq!(entries[2].entrypoint, None);
}
#[test]
fn parse_skips_non_assistant_entries() {
let lines = vec![
r#"{"type":"user","message":{"role":"user","content":"hi"}}"#.to_string(),
r#"{"type":"permission-mode"}"#.to_string(),
r#"{"type":"file-history-snapshot"}"#.to_string(),
r#"{"type":"attachment","timestamp":"2026-04-14T08:27:26.236Z"}"#.to_string(),
assistant_line("msg_01", "claude-sonnet-4-6", "2026-04-14T08:28:00.000Z", 100, 200, 0, 0),
];
let line_refs: Vec<&str> = lines.iter().map(|s| s.as_str()).collect();
let (_tmp, path) = write_jsonl(&line_refs);
let entries = parse_jsonl_entries(&path).unwrap().entries;
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].model, "claude-sonnet-4-6");
}
#[test]
fn parse_tolerates_malformed_lines_and_empty_lines() {
let good = assistant_line("msg_01", "claude-opus-4-6", "2026-04-14T08:27:38.895Z", 5, 10, 0, 0);
let lines = vec![
"",
"not json at all",
r#"{"type":"assistant","message":null}"#,
r#"{"type":"assistant","message":{"id":null}}"#,
&good,
" ",
r#"{"broken":"trailing"#,
];
let (_tmp, path) = write_jsonl(&lines);
let entries = parse_jsonl_entries(&path).unwrap().entries;
assert_eq!(entries.len(), 1);
}
#[test]
fn parse_extracts_session_id_from_file_stem() {
let line = assistant_line("msg_01", "claude-opus-4-6", "2026-04-14T08:27:38.895Z", 1, 1, 0, 0);
let (_tmp, path) = write_jsonl(&[&line]);
let entries = parse_jsonl_entries(&path).unwrap().entries;
assert_eq!(entries[0].session_id, "abc123");
}
#[test]
fn parse_parses_iso_timestamps() {
let line = assistant_line("msg_01", "claude-opus-4-6", "2026-04-14T08:27:38.895Z", 1, 1, 0, 0);
let (_tmp, path) = write_jsonl(&[&line]);
let entries = parse_jsonl_entries(&path).unwrap().entries;
assert_eq!(entries[0].timestamp_ms, 1_776_155_258_895);
}
#[test]
fn parse_skips_synthetic_model_rows() {
let real = assistant_line("msg_real", "claude-opus-4-6", "2026-04-11T00:27:38.895Z", 15, 100, 0, 0);
let synthetic = r#"{"type":"assistant","timestamp":"2026-04-11T00:27:39.000Z","message":{"id":"msg_synth","model":"<synthetic>","usage":{"input_tokens":0,"output_tokens":0,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}}"#;
let (_tmp, path) = write_jsonl(&[&real, synthetic]);
let entries = parse_jsonl_entries(&path).unwrap().entries;
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].model, "claude-opus-4-6");
}
#[test]
fn parse_handles_missing_usage_fields() {
let line = r#"{"type":"assistant","timestamp":"2026-04-14T08:27:38.895Z","message":{"id":"msg_01","model":"claude-opus-4-6"}}"#;
let (_tmp, path) = write_jsonl(&[line]);
let entries = parse_jsonl_entries(&path).unwrap().entries;
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].input_tokens, 0);
assert_eq!(entries[0].cache_read_tokens, 0);
}
#[test]
fn reduce_computes_primary_model_by_mode() {
let e = |m: &str| UsageEntry {
session_id: "s".into(),
timestamp_ms: 0,
model: m.into(),
input_tokens: 1,
output_tokens: 1,
cache_read_tokens: 0,
cache_write_tokens: 0,
tool_uses: Vec::new(),
text_chars: 0,
activity: Activity::General,
entrypoint: None,
};
let entries = vec![
e("claude-opus-4-6"),
e("claude-opus-4-6"),
e("claude-opus-4-6"),
e("claude-sonnet-4-6"),
];
let u = reduce_session(&entries, "", "sid").unwrap();
assert_eq!(u.model, "claude-opus-4-6");
assert_eq!(u.message_count, 4);
}
#[test]
fn reduce_uses_launched_model_for_context_window() {
let entries = vec![UsageEntry {
session_id: "s".into(),
timestamp_ms: 0,
model: "claude-opus-4-6".into(),
input_tokens: 1000,
output_tokens: 0,
cache_read_tokens: 0,
cache_write_tokens: 0,
tool_uses: Vec::new(),
text_chars: 0,
activity: Activity::General,
entrypoint: None,
}];
let with_1m = reduce_session(&entries, "claude-opus-4-6[1m]", "sid").unwrap();
let without = reduce_session(&entries, "", "sid").unwrap();
assert_eq!(with_1m.context_window_size, 1_000_000);
assert_eq!(without.context_window_size, 200_000);
}
#[test]
fn reduce_context_pct_uses_last_entry_only() {
let mk = |input: u64, cr: u64, cw: u64| UsageEntry {
session_id: "s".into(),
timestamp_ms: 0,
model: "claude-opus-4-6".into(),
input_tokens: input,
output_tokens: 0,
cache_read_tokens: cr,
cache_write_tokens: cw,
tool_uses: Vec::new(),
text_chars: 0,
activity: Activity::General,
entrypoint: None,
};
let entries = vec![
mk(1000, 0, 0),
mk(500, 0, 0),
mk(5, 160_000, 0), ];
let u = reduce_session(&entries, "", "sid").unwrap();
assert_eq!(u.current_context_tokens, 160_005);
assert!((u.context_window_used_pct - 80.0025).abs() < 0.01);
}
#[test]
fn reduce_cache_hit_pct() {
let e = |input: u64, cr: u64, cw: u64| UsageEntry {
session_id: "s".into(),
timestamp_ms: 0,
model: "claude-opus-4-6".into(),
input_tokens: input,
output_tokens: 1,
cache_read_tokens: cr,
cache_write_tokens: cw,
tool_uses: Vec::new(),
text_chars: 0,
activity: Activity::General,
entrypoint: None,
};
let u = reduce_session(&[e(3, 97, 0)], "", "sid").unwrap();
assert!((u.cache_hit_pct - 97.0).abs() < 0.01);
let u = reduce_session(&[e(10, 80, 10)], "", "sid").unwrap();
assert!((u.cache_hit_pct - 80.0).abs() < 0.01);
let u = reduce_session(&[e(0, 0, 0)], "", "sid").unwrap();
assert_eq!(u.cache_hit_pct, 0.0);
}
#[test]
fn reduce_total_context_sent_is_cumulative() {
let mk = |input: u64, cr: u64, cw: u64| UsageEntry {
session_id: "s".into(),
timestamp_ms: 0,
model: "claude-opus-4-6".into(),
input_tokens: input,
output_tokens: 0,
cache_read_tokens: cr,
cache_write_tokens: cw,
tool_uses: Vec::new(),
text_chars: 0,
activity: Activity::General,
entrypoint: None,
};
let u = reduce_session(
&[mk(15, 16_397, 25_661), mk(10, 42_058, 1_668)],
"",
"sid",
)
.unwrap();
assert_eq!(u.total_context_sent, 15 + 16_397 + 25_661 + 10 + 42_058 + 1_668);
}
#[test]
fn reduce_cost_matches_opus_pricing() {
let entries = vec![UsageEntry {
session_id: "s".into(),
timestamp_ms: 0,
model: "claude-opus-4-6".into(),
input_tokens: 1_000_000, output_tokens: 1_000_000, cache_read_tokens: 1_000_000, cache_write_tokens: 1_000_000, tool_uses: Vec::new(),
text_chars: 0,
activity: Activity::General,
entrypoint: None,
}];
let u = reduce_session(&entries, "", "sid").unwrap();
assert!((u.estimated_cost_usd - 36.75).abs() < 0.001);
}
#[test]
fn reduce_empty_returns_none() {
assert!(reduce_session(&[], "", "sid").is_none());
}
fn assistant_with_content(id: &str, ts: &str, content_json: &str) -> String {
format!(
r#"{{"type":"assistant","timestamp":"{ts}","message":{{"id":"{id}","model":"claude-opus-4-6","usage":{{"input_tokens":1,"output_tokens":1,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}},"content":{content_json}}}}}"#
)
}
#[test]
fn parse_extracts_tool_use_names() {
let content = r#"[{"type":"tool_use","id":"tu_1","name":"Edit","input":{"file_path":"a.rs"}},{"type":"tool_use","id":"tu_2","name":"Read","input":{"file_path":"b.rs"}}]"#;
let line = assistant_with_content("msg_1", "2026-04-14T08:27:38.895Z", content);
let (_tmp, path) = write_jsonl(&[&line]);
let entries = parse_jsonl_entries(&path).unwrap().entries;
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].tool_uses, vec!["Edit".to_string(), "Read".to_string()]);
}
#[test]
fn parse_captures_text_char_count() {
let content = r#"[{"type":"text","text":"Hello world"},{"type":"text","text":"!"}]"#;
let line = assistant_with_content("msg_1", "2026-04-14T08:27:38.895Z", content);
let (_tmp, path) = write_jsonl(&[&line]);
let entries = parse_jsonl_entries(&path).unwrap().entries;
assert_eq!(entries[0].text_chars, 12); }
#[test]
fn parse_handles_missing_content_gracefully() {
let line = assistant_line("msg_1", "claude-opus-4-6", "2026-04-14T08:27:38.895Z", 5, 10, 0, 0);
let (_tmp, path) = write_jsonl(&[&line]);
let entries = parse_jsonl_entries(&path).unwrap().entries;
assert_eq!(entries.len(), 1);
assert!(entries[0].tool_uses.is_empty());
assert_eq!(entries[0].text_chars, 0);
assert_eq!(entries[0].activity, Activity::General);
}
#[test]
fn parse_classifies_edit_plus_debug_as_debugging() {
let user_line = r#"{"type":"user","timestamp":"2026-04-14T08:27:30.000Z","message":{"role":"user","content":"Please fix the bug in login.rs"}}"#.to_string();
let content = r#"[{"type":"tool_use","id":"tu_1","name":"Edit","input":{"file_path":"login.rs"}}]"#;
let asst_line = assistant_with_content("msg_1", "2026-04-14T08:27:38.895Z", content);
let (_tmp, path) = write_jsonl(&[&user_line, &asst_line]);
let entries = parse_jsonl_entries(&path).unwrap().entries;
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].activity, Activity::Debugging);
}
#[test]
fn parse_classifies_bash_pytest_as_testing() {
let user_line = r#"{"type":"user","timestamp":"2026-04-14T08:27:30.000Z","message":{"role":"user","content":"run the tests"}}"#.to_string();
let content = r#"[{"type":"tool_use","id":"tu_1","name":"Bash","input":{"command":"pytest tests/unit"}}]"#;
let asst_line = assistant_with_content("msg_1", "2026-04-14T08:27:38.895Z", content);
let (_tmp, path) = write_jsonl(&[&user_line, &asst_line]);
let entries = parse_jsonl_entries(&path).unwrap().entries;
assert_eq!(entries[0].activity, Activity::Testing);
}
#[test]
fn parse_classifies_bash_git_as_gitops() {
let content = r#"[{"type":"tool_use","id":"tu_1","name":"Bash","input":{"command":"git push origin main"}}]"#;
let line = assistant_with_content("msg_1", "2026-04-14T08:27:38.895Z", content);
let (_tmp, path) = write_jsonl(&[&line]);
let entries = parse_jsonl_entries(&path).unwrap().entries;
assert_eq!(entries[0].activity, Activity::GitOps);
}
#[test]
fn parse_tool_result_row_does_not_overwrite_user_text() {
let user_real = r#"{"type":"user","timestamp":"2026-04-14T08:27:30.000Z","message":{"role":"user","content":"refactor the usage store"}}"#.to_string();
let asst1 = assistant_with_content(
"msg_1",
"2026-04-14T08:27:35.000Z",
r#"[{"type":"tool_use","id":"tu_1","name":"Read","input":{"file_path":"store.ts"}}]"#,
);
let tool_result = r#"{"type":"user","timestamp":"2026-04-14T08:27:36.000Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"tu_1","content":"file contents..."}]}}"#.to_string();
let asst2 = assistant_with_content(
"msg_2",
"2026-04-14T08:27:40.000Z",
r#"[{"type":"tool_use","id":"tu_2","name":"Edit","input":{"file_path":"store.ts"}}]"#,
);
let (_tmp, path) = write_jsonl(&[&user_real, &asst1, &tool_result, &asst2]);
let entries = parse_jsonl_entries(&path).unwrap().entries;
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].activity, Activity::Exploration);
assert_eq!(entries[1].activity, Activity::Refactor);
}
#[test]
fn parse_classifies_agent_turn_as_delegation() {
let content = r#"[{"type":"tool_use","id":"tu_1","name":"Agent","input":{"description":"explore"}}]"#;
let line = assistant_with_content("msg_1", "2026-04-14T08:27:38.895Z", content);
let (_tmp, path) = write_jsonl(&[&line]);
let entries = parse_jsonl_entries(&path).unwrap().entries;
assert_eq!(entries[0].activity, Activity::Delegation);
}
#[test]
fn parse_pure_text_turn_is_conversation() {
let user_line = r#"{"type":"user","timestamp":"2026-04-14T08:27:30.000Z","message":{"role":"user","content":"thanks"}}"#.to_string();
let content = r#"[{"type":"text","text":"You're welcome — anything else?"}]"#;
let asst_line = assistant_with_content("msg_1", "2026-04-14T08:27:38.895Z", content);
let (_tmp, path) = write_jsonl(&[&user_line, &asst_line]);
let entries = parse_jsonl_entries(&path).unwrap().entries;
assert_eq!(entries[0].activity, Activity::Conversation);
assert!(entries[0].text_chars > 0);
}
#[test]
fn parse_user_array_content_extracts_text_blocks() {
let user_line = r#"{"type":"user","timestamp":"2026-04-14T08:27:30.000Z","message":{"role":"user","content":[{"type":"text","text":"fix the crash"}]}}"#.to_string();
let content = r#"[{"type":"tool_use","id":"tu_1","name":"Edit","input":{}}]"#;
let asst_line = assistant_with_content("msg_1", "2026-04-14T08:27:38.895Z", content);
let (_tmp, path) = write_jsonl(&[&user_line, &asst_line]);
let entries = parse_jsonl_entries(&path).unwrap().entries;
assert_eq!(entries[0].activity, Activity::Debugging);
}
fn assistant_split_row(id: &str, ts: &str, content_json: &str) -> String {
format!(
r#"{{"type":"assistant","timestamp":"{ts}","message":{{"id":"{id}","model":"claude-opus-4-7","usage":{{"input_tokens":6,"output_tokens":746,"cache_read_input_tokens":21859,"cache_creation_input_tokens":33027}},"content":{content_json}}}}}"#
)
}
#[test]
fn parse_merges_tool_uses_across_split_content_rows() {
let thinking = assistant_split_row(
"msg_01M6AvUVpAFjzSWcXeyGzUwV",
"2026-05-13T09:09:03.500Z",
r#"[{"type":"thinking","thinking":"plan"}]"#,
);
let text = assistant_split_row(
"msg_01M6AvUVpAFjzSWcXeyGzUwV",
"2026-05-13T09:09:03.550Z",
r#"[{"type":"text","text":"Running tools in parallel."}]"#,
);
let tu1 = assistant_split_row(
"msg_01M6AvUVpAFjzSWcXeyGzUwV",
"2026-05-13T09:09:03.600Z",
r#"[{"type":"tool_use","id":"tu_1","name":"mcp__example__list_configs","input":{"area":"rules"}}]"#,
);
let tu2 = assistant_split_row(
"msg_01M6AvUVpAFjzSWcXeyGzUwV",
"2026-05-13T09:09:03.700Z",
r#"[{"type":"tool_use","id":"tu_2","name":"mcp__example__get_report","input":{}}]"#,
);
let tu3 = assistant_split_row(
"msg_01M6AvUVpAFjzSWcXeyGzUwV",
"2026-05-13T09:09:03.800Z",
r#"[{"type":"tool_use","id":"tu_3","name":"Edit","input":{"file_path":"a.rs"}}]"#,
);
let (_tmp, path) = write_jsonl(&[&thinking, &text, &tu1, &tu2, &tu3]);
let entries = parse_jsonl_entries(&path).unwrap().entries;
assert_eq!(entries.len(), 1, "still one entry per message.id");
let e = &entries[0];
assert_eq!(
e.tool_uses,
vec![
"mcp__example__list_configs".to_string(),
"mcp__example__get_report".to_string(),
"Edit".to_string(),
],
"all tool_use names merged in row order"
);
assert!(e.text_chars > 0, "text from the text row is preserved");
assert_eq!(e.input_tokens, 6);
assert_eq!(e.output_tokens, 746);
assert_eq!(e.cache_read_tokens, 21859);
assert_eq!(e.cache_write_tokens, 33027);
}
#[test]
fn parse_split_row_classifies_against_merged_content() {
let user = r#"{"type":"user","timestamp":"2026-05-13T09:09:00.000Z","message":{"role":"user","content":"please fix the bug in auth.rs"}}"#.to_string();
let thinking = assistant_split_row(
"msg_split",
"2026-05-13T09:09:01.000Z",
r#"[{"type":"thinking","thinking":"investigating"}]"#,
);
let edit = assistant_split_row(
"msg_split",
"2026-05-13T09:09:02.000Z",
r#"[{"type":"tool_use","id":"tu_1","name":"Edit","input":{"file_path":"auth.rs"}}]"#,
);
let (_tmp, path) = write_jsonl(&[&user, &thinking, &edit]);
let entries = parse_jsonl_entries(&path).unwrap().entries;
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].activity, Activity::Debugging);
assert_eq!(entries[0].tool_uses, vec!["Edit".to_string()]);
}
#[test]
fn parse_split_row_bash_commands_feed_classifier() {
let user = r#"{"type":"user","timestamp":"2026-05-13T09:09:00.000Z","message":{"role":"user","content":"run tests"}}"#.to_string();
let text = assistant_split_row(
"msg_bash",
"2026-05-13T09:09:01.000Z",
r#"[{"type":"text","text":"Running the unit tests."}]"#,
);
let bash = assistant_split_row(
"msg_bash",
"2026-05-13T09:09:02.000Z",
r#"[{"type":"tool_use","id":"tu_1","name":"Bash","input":{"command":"pytest tests/"}}]"#,
);
let (_tmp, path) = write_jsonl(&[&user, &text, &bash]);
let entries = parse_jsonl_entries(&path).unwrap().entries;
assert_eq!(entries[0].activity, Activity::Testing);
}
#[test]
fn parse_extracts_hook_success_events() {
let hook_line = r#"{"type":"attachment","timestamp":"2026-04-15T18:08:50.000Z","attachment":{"type":"hook_success","hookName":"PostToolUse:Edit","hookEvent":"PostToolUse","toolUseID":"tu_abc","command":"verify-edit.sh","exitCode":0,"durationMs":90}}"#;
let hook_fail = r#"{"type":"attachment","timestamp":"2026-04-15T18:09:00.000Z","attachment":{"type":"hook_success","hookName":"PreToolUse:Bash","hookEvent":"PreToolUse","toolUseID":"tu_def","command":"lint-check.sh","exitCode":1,"durationMs":250}}"#;
let hook_msg = r#"{"type":"attachment","timestamp":"2026-04-15T18:09:01.000Z","attachment":{"type":"hook_system_message","hookName":"Stop","hookEvent":"Stop","content":"reminder text"}}"#;
let asst = assistant_line("msg_01", "claude-opus-4-6", "2026-04-15T18:08:51.000Z", 5, 10, 0, 0);
let (_tmp, path) = write_jsonl(&[hook_line, hook_fail, hook_msg, &asst]);
let parsed = parse_jsonl_entries(&path).unwrap();
assert_eq!(parsed.entries.len(), 1, "assistant entries unaffected");
assert_eq!(parsed.hooks.len(), 2, "two hook_success events");
assert_eq!(parsed.hooks[0].hook_name, "PostToolUse:Edit");
assert_eq!(parsed.hooks[0].hook_event, "PostToolUse");
assert_eq!(parsed.hooks[0].command, "verify-edit.sh");
assert_eq!(parsed.hooks[0].exit_code, 0);
assert_eq!(parsed.hooks[0].duration_ms, 90);
assert_eq!(parsed.hooks[0].tool_use_id.as_deref(), Some("tu_abc"));
assert_eq!(parsed.hooks[1].hook_name, "PreToolUse:Bash");
assert_eq!(parsed.hooks[1].exit_code, 1);
assert_eq!(parsed.hooks[1].duration_ms, 250);
assert_eq!(parsed.hooks[1].tool_use_id.as_deref(), Some("tu_def"));
}
#[test]
fn parse_ignores_attachment_without_hook_data() {
let non_hook = r#"{"type":"attachment","timestamp":"2026-04-14T08:27:26.236Z","attachment":{"type":"file_content","path":"foo.rs"}}"#;
let asst = assistant_line("msg_01", "claude-opus-4-6", "2026-04-14T08:28:00.000Z", 1, 1, 0, 0);
let (_tmp, path) = write_jsonl(&[non_hook, &asst]);
let parsed = parse_jsonl_entries(&path).unwrap();
assert_eq!(parsed.entries.len(), 1);
assert!(parsed.hooks.is_empty());
}
#[test]
fn snake_to_pascal_converts_correctly() {
assert_eq!(snake_to_pascal("stop"), "Stop");
assert_eq!(snake_to_pascal("pre_tool_use"), "PreToolUse");
assert_eq!(snake_to_pascal("post_tool_use"), "PostToolUse");
assert_eq!(snake_to_pascal("session_start"), "SessionStart");
}
#[test]
fn parse_extracts_system_hook_summary_v2() {
let stop_summary = r#"{"type":"system","subtype":"stop_hook_summary","hookCount":1,"hookInfos":[{"command":"Running build checks...","durationMs":5227}],"hookErrors":[],"preventedContinuation":false,"stopReason":"","hasOutput":false,"level":"suggestion","timestamp":"2026-04-25T09:06:12.854Z","uuid":"abc","toolUseID":"tu_xyz","sessionId":"sess-1","version":"2.1.119"}"#;
let asst = assistant_line("msg_01", "claude-opus-4-6", "2026-04-25T09:06:13.000Z", 5, 10, 0, 0);
let (_tmp, path) = write_jsonl(&[stop_summary, &asst]);
let parsed = parse_jsonl_entries(&path).unwrap();
assert_eq!(parsed.entries.len(), 1);
assert_eq!(parsed.hooks.len(), 1);
let h = &parsed.hooks[0];
assert_eq!(h.hook_event, "Stop");
assert_eq!(h.command, "Running build checks...");
assert_eq!(h.duration_ms, 5227);
assert_eq!(h.exit_code, 0, "no errors and not prevented = success");
assert_eq!(h.tool_use_id.as_deref(), Some("tu_xyz"));
}
#[test]
fn parse_system_hook_summary_with_errors() {
let summary = r#"{"type":"system","subtype":"stop_hook_summary","hookCount":1,"hookInfos":[{"command":"cargo check","durationMs":3000}],"hookErrors":["something broke"],"preventedContinuation":true,"timestamp":"2026-04-25T10:00:00.000Z","uuid":"def","toolUseID":"tu_err","sessionId":"sess-2","version":"2.1.119"}"#;
let asst = assistant_line("msg_02", "claude-opus-4-6", "2026-04-25T10:00:01.000Z", 1, 1, 0, 0);
let (_tmp, path) = write_jsonl(&[summary, &asst]);
let parsed = parse_jsonl_entries(&path).unwrap();
assert_eq!(parsed.hooks.len(), 1);
assert_eq!(parsed.hooks[0].exit_code, 1, "errors or prevented = failure");
}
#[test]
fn parse_system_hook_summary_multiple_hooks() {
let summary = r#"{"type":"system","subtype":"stop_hook_summary","hookCount":2,"hookInfos":[{"command":"tsc --noEmit","durationMs":1200},{"command":"cargo check","durationMs":3500}],"hookErrors":[],"preventedContinuation":false,"timestamp":"2026-04-25T11:00:00.000Z","uuid":"ghi","toolUseID":"tu_multi","sessionId":"sess-3","version":"2.1.119"}"#;
let asst = assistant_line("msg_03", "claude-opus-4-6", "2026-04-25T11:00:01.000Z", 1, 1, 0, 0);
let (_tmp, path) = write_jsonl(&[summary, &asst]);
let parsed = parse_jsonl_entries(&path).unwrap();
assert_eq!(parsed.hooks.len(), 2);
assert_eq!(parsed.hooks[0].command, "tsc --noEmit");
assert_eq!(parsed.hooks[0].duration_ms, 1200);
assert_eq!(parsed.hooks[1].command, "cargo check");
assert_eq!(parsed.hooks[1].duration_ms, 3500);
assert_eq!(parsed.hooks[0].tool_use_id, parsed.hooks[1].tool_use_id);
}
#[test]
fn hook_name_label_sanitizes_long_multiline_commands() {
let long_cmd = "Goal statement: Achieve >=3x better compression ratio than zstd -19 --long=27 and xz -9e on the Lichess 2024-01 corpus.\n\n3. Validation strategy:\n- (a) Fixed + held-out corpora.";
let escaped = serde_json::to_string(long_cmd).unwrap();
let summary = format!(
r#"{{"type":"system","subtype":"stop_hook_summary","hookCount":1,"hookInfos":[{{"command":{escaped},"durationMs":42}}],"hookErrors":[],"preventedContinuation":false,"timestamp":"2026-04-25T12:00:00.000Z","uuid":"goal","toolUseID":"tu_goal","sessionId":"sess-goal","version":"2.1.119"}}"#
);
let asst = assistant_line("msg_goal", "claude-opus-4-6", "2026-04-25T12:00:01.000Z", 1, 1, 0, 0);
let (_tmp, path) = write_jsonl(&[&summary, &asst]);
let parsed = parse_jsonl_entries(&path).unwrap();
assert_eq!(parsed.hooks.len(), 1);
let h = &parsed.hooks[0];
assert_eq!(h.command, long_cmd, "full command preserved on the entry");
assert!(h.hook_name.starts_with("Stop:"), "name still scoped by event");
assert!(h.hook_name.chars().count() <= "Stop:".len() + 65);
assert!(h.hook_name.ends_with('…'), "long commands get ellipsis: {}", h.hook_name);
assert!(!h.hook_name.contains('\n'), "no newlines in bucket key");
}
#[test]
fn hook_name_label_preserves_short_commands() {
let summary = r#"{"type":"system","subtype":"stop_hook_summary","hookCount":1,"hookInfos":[{"command":"cargo check","durationMs":3000}],"hookErrors":[],"preventedContinuation":false,"timestamp":"2026-04-25T13:00:00.000Z","uuid":"short","toolUseID":"tu_short","sessionId":"sess-short","version":"2.1.119"}"#;
let asst = assistant_line("msg_short", "claude-opus-4-6", "2026-04-25T13:00:01.000Z", 1, 1, 0, 0);
let (_tmp, path) = write_jsonl(&[summary, &asst]);
let parsed = parse_jsonl_entries(&path).unwrap();
assert_eq!(parsed.hooks[0].hook_name, "Stop:cargo check");
}
#[test]
fn parse_handles_both_formats_in_same_file() {
let old_hook = r#"{"type":"attachment","timestamp":"2026-04-23T14:05:05.937Z","attachment":{"type":"hook_success","hookName":"PostToolUse:Edit","hookEvent":"PostToolUse","toolUseID":"tu_old","command":"lint.sh","exitCode":0,"durationMs":90}}"#;
let new_hook = r#"{"type":"system","subtype":"stop_hook_summary","hookCount":1,"hookInfos":[{"command":"cargo check","durationMs":5000}],"hookErrors":[],"preventedContinuation":false,"timestamp":"2026-04-25T09:06:12.854Z","uuid":"both","toolUseID":"tu_new","sessionId":"sess-both","version":"2.1.119"}"#;
let asst = assistant_line("msg_both", "claude-opus-4-6", "2026-04-25T09:07:00.000Z", 1, 1, 0, 0);
let (_tmp, path) = write_jsonl(&[old_hook, new_hook, &asst]);
let parsed = parse_jsonl_entries(&path).unwrap();
assert_eq!(parsed.hooks.len(), 2, "both formats parsed");
assert_eq!(parsed.hooks[0].hook_event, "PostToolUse");
assert_eq!(parsed.hooks[1].hook_event, "Stop");
}
}