1use chrono::DateTime;
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use std::fs;
5use std::io::{BufRead, BufReader};
6use std::path::{Path, PathBuf};
7use std::time::SystemTime;
8
9use super::activity::{classify, normalize_user_text, Activity};
10
11pub struct ModelPricing {
13 pub input: f64,
14 pub output: f64,
15 pub cache_read: f64,
16 pub cache_write: f64,
17}
18
19pub fn pricing_for_model(model: &str) -> ModelPricing {
21 if model.contains("opus") {
22 ModelPricing {
23 input: 5.0,
24 output: 25.0,
25 cache_read: 0.5,
26 cache_write: 6.25,
27 }
28 } else if model.contains("haiku") {
29 ModelPricing {
30 input: 1.0,
31 output: 5.0,
32 cache_read: 0.1,
33 cache_write: 1.25,
34 }
35 } else {
36 ModelPricing {
38 input: 3.0,
39 output: 15.0,
40 cache_read: 0.3,
41 cache_write: 3.75,
42 }
43 }
44}
45
46pub fn context_window_for_model(model: &str) -> u64 {
47 if model.contains("[1m]") {
48 1_000_000
49 } else {
50 200_000
51 }
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
70#[serde(rename_all = "camelCase")]
71pub struct UsageEntry {
72 pub session_id: String,
73 pub timestamp_ms: u64,
75 pub model: String,
76 pub input_tokens: u64,
77 pub output_tokens: u64,
78 pub cache_read_tokens: u64,
79 pub cache_write_tokens: u64,
80 pub tool_uses: Vec<String>,
84 pub text_chars: u32,
87 pub activity: Activity,
90 pub entrypoint: Option<String>,
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize)]
102#[serde(rename_all = "camelCase")]
103pub struct HookExecution {
104 pub session_id: String,
105 pub timestamp_ms: u64,
106 pub hook_event: String,
108 pub hook_name: String,
110 pub command: String,
112 pub exit_code: i32,
113 pub duration_ms: u64,
114 pub tool_use_id: Option<String>,
116}
117
118#[derive(Default)]
120pub struct ParsedJsonl {
121 pub entries: Vec<UsageEntry>,
122 pub hooks: Vec<HookExecution>,
123}
124
125#[derive(Debug, Clone, Serialize, Default)]
127#[serde(rename_all = "camelCase")]
128pub struct SessionUsage {
129 pub model: String,
130 pub input_tokens: u64,
131 pub output_tokens: u64,
132 pub cache_read_tokens: u64,
133 pub cache_write_tokens: u64,
134 pub total_tokens: u64,
135 pub estimated_cost_usd: f64,
136 pub context_window_size: u64,
137 pub context_window_used_pct: f64,
138 pub current_context_tokens: u64,
140 pub total_context_sent: u64,
142 pub cache_hit_pct: f64,
144 pub message_count: u64,
145 pub session_id: String,
146}
147
148#[derive(Deserialize)]
150#[serde(rename_all = "camelCase")]
151struct JsonlRaw {
152 #[serde(rename = "type")]
153 entry_type: Option<String>,
154 timestamp: Option<String>,
155 message: Option<MessageData>,
156 attachment: Option<AttachmentData>,
157 subtype: Option<String>,
159 #[allow(dead_code)]
160 hook_count: Option<usize>,
161 hook_infos: Option<Vec<HookInfoEntry>>,
162 hook_errors: Option<Vec<serde_json::Value>>,
163 prevented_continuation: Option<bool>,
164 #[serde(rename = "toolUseID")]
165 tool_use_id: Option<String>,
166 entrypoint: Option<String>,
169}
170
171#[derive(Deserialize)]
172struct MessageData {
173 id: Option<String>,
174 model: Option<String>,
175 usage: Option<UsageData>,
176 #[serde(default)]
181 content: Option<serde_json::Value>,
182}
183
184#[derive(Deserialize, Default)]
185struct UsageData {
186 input_tokens: Option<u64>,
187 output_tokens: Option<u64>,
188 cache_read_input_tokens: Option<u64>,
189 cache_creation_input_tokens: Option<u64>,
190}
191
192#[derive(Deserialize)]
194#[serde(rename_all = "camelCase")]
195struct HookInfoEntry {
196 command: Option<String>,
197 duration_ms: Option<u64>,
198}
199
200#[derive(Deserialize)]
202#[serde(rename_all = "camelCase")]
203struct AttachmentData {
204 #[serde(rename = "type")]
205 attachment_type: Option<String>,
206 hook_name: Option<String>,
207 hook_event: Option<String>,
208 command: Option<String>,
209 exit_code: Option<i32>,
210 duration_ms: Option<u64>,
211 #[serde(rename = "toolUseID")]
212 tool_use_id: Option<String>,
213}
214
215#[derive(Deserialize)]
217#[serde(rename_all = "camelCase")]
218struct ActiveSession {
219 session_id: String,
220 cwd: String,
221 started_at: u64,
222}
223
224use super::MAX_JSONL_BYTES;
231
232pub fn encode_project_path(project_path: &str) -> String {
242 let normalized = project_path.replace('/', "\\");
243 let mut result = String::new();
244 let mut chars = normalized.chars().peekable();
245
246 while let Some(ch) = chars.next() {
247 if ch == ':' {
248 if chars.peek() == Some(&'\\') {
249 chars.next();
250 result.push_str("--");
251 } else {
252 result.push('-');
253 }
254 } else if ch == '\\' {
255 result.push('-');
256 } else {
257 result.push(ch);
258 }
259 }
260
261 while result.ends_with('-') {
262 result.pop();
263 }
264
265 result
266}
267
268struct AssistantContent {
270 tool_uses: Vec<String>,
271 bash_cmds: Vec<String>,
272 text_chars: u32,
273}
274
275fn extract_assistant_content(content: Option<&serde_json::Value>) -> AssistantContent {
279 let mut out = AssistantContent {
280 tool_uses: Vec::new(),
281 bash_cmds: Vec::new(),
282 text_chars: 0,
283 };
284 let arr = match content.and_then(|v| v.as_array()) {
285 Some(a) => a,
286 None => return out,
287 };
288
289 for block in arr {
290 let block_type = match block.get("type").and_then(|v| v.as_str()) {
291 Some(t) => t,
292 None => continue,
293 };
294 match block_type {
295 "tool_use" => {
296 if let Some(name) = block.get("name").and_then(|v| v.as_str()) {
297 if matches!(name, "Bash" | "BashTool" | "PowerShellTool") {
300 if let Some(cmd) = block
301 .get("input")
302 .and_then(|i| i.get("command"))
303 .and_then(|c| c.as_str())
304 {
305 out.bash_cmds.push(cmd.to_string());
306 }
307 }
308 out.tool_uses.push(name.to_string());
309 }
310 }
311 "text" => {
312 if let Some(text) = block.get("text").and_then(|v| v.as_str()) {
313 out.text_chars = out
316 .text_chars
317 .saturating_add(text.chars().count() as u32);
318 }
319 }
320 _ => {}
321 }
322 }
323
324 out
325}
326
327fn sanitize_hook_command_label(cmd: &str) -> String {
346 const MAX_LEN: usize = 64;
347 let first_line = cmd.lines().next().unwrap_or("").trim();
348 let collapsed: String = first_line.split_whitespace().collect::<Vec<_>>().join(" ");
349 if collapsed.chars().count() <= MAX_LEN {
350 return collapsed;
351 }
352 let truncated: String = collapsed.chars().take(MAX_LEN).collect();
353 format!("{truncated}…")
354}
355
356fn snake_to_pascal(s: &str) -> String {
358 s.split('_')
359 .filter(|w| !w.is_empty())
360 .map(|w| {
361 let mut c = w.chars();
362 match c.next() {
363 Some(first) => {
364 let mut out = first.to_uppercase().to_string();
365 out.extend(c);
366 out
367 }
368 None => String::new(),
369 }
370 })
371 .collect()
372}
373
374fn extract_user_text(content: &serde_json::Value) -> Option<String> {
377 match content {
378 serde_json::Value::String(s) => {
379 if s.is_empty() {
380 None
381 } else {
382 Some(s.clone())
383 }
384 }
385 serde_json::Value::Array(arr) => {
386 let mut parts: Vec<&str> = Vec::new();
387 for item in arr {
388 let t = item.get("type").and_then(|v| v.as_str()).unwrap_or("");
389 if t == "text" {
390 if let Some(text) = item.get("text").and_then(|v| v.as_str()) {
391 if !text.is_empty() {
392 parts.push(text);
393 }
394 }
395 }
396 }
397 if parts.is_empty() {
398 None
399 } else {
400 Some(parts.join(" "))
401 }
402 }
403 _ => None,
404 }
405}
406
407struct EntryAccumulator {
410 out_idx: usize,
411 bash_cmds: Vec<String>,
412 user_text_at_first: Option<String>,
413}
414
415pub fn parse_jsonl_entries(path: &Path) -> std::io::Result<ParsedJsonl> {
439 let meta = fs::metadata(path)?;
440 if meta.len() > MAX_JSONL_BYTES {
441 tracing::warn!(
442 path = %path.display(),
443 size = meta.len(),
444 "skipping JSONL larger than {}MB",
445 MAX_JSONL_BYTES / 1024 / 1024
446 );
447 return Ok(ParsedJsonl::default());
448 }
449
450 let file = fs::File::open(path)?;
451 let reader = BufReader::new(file);
452
453 let session_id = path
454 .file_stem()
455 .and_then(|s| s.to_str())
456 .unwrap_or("")
457 .to_string();
458
459 let mut accums: HashMap<String, EntryAccumulator> = HashMap::new();
460 let mut out: Vec<UsageEntry> = Vec::new();
461 let mut hooks: Vec<HookExecution> = Vec::new();
462 let mut last_user_text: Option<String> = None;
463
464 for line in reader.lines() {
465 let line = match line {
466 Ok(l) => l,
467 Err(_) => continue,
468 };
469 let line = line.trim();
470 if line.is_empty() {
471 continue;
472 }
473
474 let raw: JsonlRaw = match serde_json::from_str(line) {
475 Ok(r) => r,
476 Err(_) => continue,
477 };
478
479 let entry_type = raw.entry_type.as_deref();
480
481 if entry_type == Some("user") {
484 if let Some(msg) = raw.message.as_ref() {
485 if let Some(content) = msg.content.as_ref() {
486 if let Some(text) = extract_user_text(content) {
487 last_user_text = Some(normalize_user_text(&text));
488 }
489 }
490 }
491 continue;
492 }
493
494 if entry_type == Some("attachment") {
496 if let Some(att) = raw.attachment {
497 if att.attachment_type.as_deref() == Some("hook_success") {
498 let timestamp_ms = raw
499 .timestamp
500 .as_deref()
501 .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
502 .map(|dt| dt.timestamp_millis().max(0) as u64)
503 .unwrap_or(0);
504 hooks.push(HookExecution {
505 session_id: session_id.clone(),
506 timestamp_ms,
507 hook_event: att.hook_event.unwrap_or_default(),
508 hook_name: att.hook_name.unwrap_or_default(),
509 command: att.command.unwrap_or_default(),
510 exit_code: att.exit_code.unwrap_or(0),
511 duration_ms: att.duration_ms.unwrap_or(0),
512 tool_use_id: att.tool_use_id,
513 });
514 }
515 }
516 continue;
517 }
518
519 if entry_type == Some("system") {
522 if let Some(subtype) = raw.subtype.as_deref() {
523 if let Some(event_snake) = subtype.strip_suffix("_hook_summary") {
524 let hook_event = snake_to_pascal(event_snake);
525 let timestamp_ms = raw
526 .timestamp
527 .as_deref()
528 .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
529 .map(|dt| dt.timestamp_millis().max(0) as u64)
530 .unwrap_or(0);
531 let had_errors = raw
532 .hook_errors
533 .as_ref()
534 .is_some_and(|e| !e.is_empty());
535 let prevented = raw.prevented_continuation.unwrap_or(false);
536 if let Some(infos) = raw.hook_infos {
537 for info in infos {
538 let cmd = info.command.unwrap_or_default();
539 let hook_name =
540 format!("{}:{}", hook_event, sanitize_hook_command_label(&cmd));
541 hooks.push(HookExecution {
542 session_id: session_id.clone(),
543 timestamp_ms,
544 hook_event: hook_event.clone(),
545 hook_name,
546 command: cmd,
547 exit_code: if had_errors || prevented { 1 } else { 0 },
548 duration_ms: info.duration_ms.unwrap_or(0),
549 tool_use_id: raw.tool_use_id.clone(),
550 });
551 }
552 }
553 }
554 }
555 continue;
556 }
557
558 if entry_type != Some("assistant") {
559 continue;
560 }
561
562 let msg = match raw.message {
563 Some(m) => m,
564 None => continue,
565 };
566 let mid = match msg.id {
567 Some(id) => id,
568 None => continue,
569 };
570
571 let mut content_parsed = extract_assistant_content(msg.content.as_ref());
572
573 if let Some(acc) = accums.get_mut(&mid) {
576 let entry = &mut out[acc.out_idx];
577 entry
578 .tool_uses
579 .append(&mut content_parsed.tool_uses);
580 entry.text_chars = entry
581 .text_chars
582 .saturating_add(content_parsed.text_chars);
583 acc.bash_cmds.append(&mut content_parsed.bash_cmds);
584 continue;
585 }
586
587 let model = msg.model.unwrap_or_default();
593 if model == "<synthetic>" {
594 continue;
595 }
596
597 let usage = msg.usage.unwrap_or_default();
598 let timestamp_ms = raw
599 .timestamp
600 .as_deref()
601 .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
602 .map(|dt| dt.timestamp_millis().max(0) as u64)
603 .unwrap_or(0);
604
605 accums.insert(
609 mid.clone(),
610 EntryAccumulator {
611 out_idx: out.len(),
612 bash_cmds: std::mem::take(&mut content_parsed.bash_cmds),
613 user_text_at_first: last_user_text.clone(),
614 },
615 );
616 out.push(UsageEntry {
617 session_id: session_id.clone(),
618 timestamp_ms,
619 model,
620 input_tokens: usage.input_tokens.unwrap_or(0),
621 output_tokens: usage.output_tokens.unwrap_or(0),
622 cache_read_tokens: usage.cache_read_input_tokens.unwrap_or(0),
623 cache_write_tokens: usage.cache_creation_input_tokens.unwrap_or(0),
624 tool_uses: content_parsed.tool_uses,
625 text_chars: content_parsed.text_chars,
626 activity: Activity::General,
627 entrypoint: raw.entrypoint.clone(),
628 });
629 }
630
631 for acc in accums.values() {
634 let entry = &mut out[acc.out_idx];
635 let tool_refs: Vec<&str> = entry.tool_uses.iter().map(|s| s.as_str()).collect();
636 let bash_refs: Vec<&str> = acc.bash_cmds.iter().map(|s| s.as_str()).collect();
637 entry.activity = classify(
638 &tool_refs,
639 &bash_refs,
640 acc.user_text_at_first.as_deref(),
641 entry.text_chars,
642 );
643 }
644
645 Ok(ParsedJsonl {
646 entries: out,
647 hooks,
648 })
649}
650
651pub fn find_current_session_jsonl(
655 config_dir: &str,
656 project_path: &str,
657 spawned_at: SystemTime,
658) -> Option<PathBuf> {
659 let config = Path::new(config_dir);
660 let encoded = encode_project_path(project_path);
661 let projects_dir = config.join("projects").join(&encoded);
662
663 if !projects_dir.exists() {
664 return None;
665 }
666
667 let sessions_dir = config.join("sessions");
670 if sessions_dir.exists() {
671 if let Some(session_id) = find_active_session_id(&sessions_dir, project_path, spawned_at) {
672 let jsonl_path = projects_dir.join(format!("{}.jsonl", session_id));
673 if jsonl_path.exists() {
674 return Some(jsonl_path);
675 }
676 }
677 }
678
679 let mut candidates: Vec<(PathBuf, SystemTime)> = Vec::new();
684
685 if let Ok(entries) = fs::read_dir(&projects_dir) {
686 for entry in entries.flatten() {
687 let path = entry.path();
688 if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
689 continue;
690 }
691 if let Ok(meta) = path.metadata() {
692 let created = meta.created().unwrap_or(SystemTime::UNIX_EPOCH);
693 if created >= spawned_at {
694 candidates.push((path, created));
695 }
696 }
697 }
698 }
699
700 candidates.sort_by(|a, b| a.1.cmp(&b.1));
702 candidates.into_iter().next().map(|(p, _)| p)
703}
704
705fn find_active_session_id(
708 sessions_dir: &Path,
709 project_path: &str,
710 spawned_at: SystemTime,
711) -> Option<String> {
712 let spawned_ms = spawned_at
713 .duration_since(SystemTime::UNIX_EPOCH)
714 .ok()?
715 .as_millis() as u64;
716
717 let threshold_ms = spawned_ms.saturating_sub(10_000);
719
720 let normalized_project = normalize_path(project_path);
721
722 let mut best: Option<(String, u64)> = None;
723
724 if let Ok(entries) = fs::read_dir(sessions_dir) {
725 for entry in entries.flatten() {
726 let path = entry.path();
727 if path.extension().and_then(|e| e.to_str()) != Some("json") {
728 continue;
729 }
730 if let Ok(content) = fs::read_to_string(&path) {
731 if let Ok(session) = serde_json::from_str::<ActiveSession>(&content) {
732 let session_cwd = normalize_path(&session.cwd);
733 if session_cwd == normalized_project && session.started_at >= threshold_ms {
734 if best.as_ref().is_none_or(|(_, t)| session.started_at < *t) {
740 best = Some((session.session_id, session.started_at));
741 }
742 }
743 }
744 }
745 }
746 }
747
748 best.map(|(id, _)| id)
749}
750
751fn normalize_path(p: &str) -> String {
753 p.replace('\\', "/")
754 .trim_end_matches('/')
755 .to_lowercase()
756}
757
758pub fn reduce_session(
767 entries: &[UsageEntry],
768 launched_model: &str,
769 session_id_hint: &str,
770) -> Option<SessionUsage> {
771 if entries.is_empty() {
772 return None;
773 }
774
775 let mut input_tokens = 0u64;
776 let mut output_tokens = 0u64;
777 let mut cache_read = 0u64;
778 let mut cache_write = 0u64;
779 let mut model_counts: HashMap<&str, u64> = HashMap::new();
780
781 for e in entries {
782 input_tokens += e.input_tokens;
783 output_tokens += e.output_tokens;
784 cache_read += e.cache_read_tokens;
785 cache_write += e.cache_write_tokens;
786 if !e.model.is_empty() {
787 *model_counts.entry(e.model.as_str()).or_insert(0) += 1;
788 }
789 }
790
791 let primary_model = model_counts
792 .into_iter()
793 .max_by_key(|(_, c)| *c)
794 .map(|(m, _)| m.to_string())
795 .unwrap_or_else(|| {
796 entries
797 .last()
798 .map(|e| e.model.clone())
799 .unwrap_or_default()
800 });
801
802 let pricing = pricing_for_model(&primary_model);
803 let estimated_cost = (input_tokens as f64 * pricing.input
804 + output_tokens as f64 * pricing.output
805 + cache_read as f64 * pricing.cache_read
806 + cache_write as f64 * pricing.cache_write)
807 / 1_000_000.0;
808
809 let context_model = if launched_model.is_empty() {
810 &primary_model
811 } else {
812 launched_model
813 };
814 let context_window = context_window_for_model(context_model);
815
816 let last = entries.last().unwrap();
817 let last_context_used =
818 last.input_tokens + last.cache_read_tokens + last.cache_write_tokens;
819 let context_pct = if context_window > 0 {
820 (last_context_used as f64 / context_window as f64 * 100.0).min(100.0)
821 } else {
822 0.0
823 };
824
825 let total = input_tokens + output_tokens + cache_read + cache_write;
826 let total_context_sent = input_tokens + cache_read + cache_write;
827
828 let cache_denom = input_tokens + cache_read + cache_write;
833 let cache_hit_pct = if cache_denom > 0 {
834 (cache_read as f64 / cache_denom as f64) * 100.0
835 } else {
836 0.0
837 };
838
839 let session_id = entries
840 .first()
841 .map(|e| e.session_id.clone())
842 .filter(|s| !s.is_empty())
843 .unwrap_or_else(|| session_id_hint.to_string());
844
845 Some(SessionUsage {
846 model: primary_model,
847 input_tokens,
848 output_tokens,
849 cache_read_tokens: cache_read,
850 cache_write_tokens: cache_write,
851 total_tokens: total,
852 estimated_cost_usd: estimated_cost,
853 context_window_size: context_window,
854 context_window_used_pct: context_pct,
855 current_context_tokens: last_context_used,
856 total_context_sent,
857 cache_hit_pct,
858 message_count: entries.len() as u64,
859 session_id,
860 })
861}
862
863#[cfg(test)]
864mod tests {
865 use super::*;
866 use std::io::Write;
867 use tempfile::TempDir;
868
869 #[test]
870 fn test_encode_project_path_drive_root() {
871 assert_eq!(encode_project_path("S:\\Loom"), "S--Loom");
872 }
873
874 #[test]
875 fn test_encode_project_path_deep() {
876 assert_eq!(
877 encode_project_path("C:\\Users\\Seven\\Desktop\\SF\\project"),
878 "C--Users-Seven-Desktop-SF-project"
879 );
880 }
881
882 #[test]
883 fn test_encode_project_path_forward_slashes() {
884 assert_eq!(encode_project_path("S:/Loom"), "S--Loom");
885 }
886
887 #[test]
888 fn test_encode_project_path_trailing_slash() {
889 assert_eq!(encode_project_path("S:\\Loom\\"), "S--Loom");
890 }
891
892 #[test]
893 fn test_encode_project_path_macos() {
894 assert_eq!(
895 encode_project_path("/Users/seven/Loom"),
896 "-Users-seven-Loom"
897 );
898 }
899
900 #[test]
901 fn test_encode_project_path_linux() {
902 assert_eq!(
903 encode_project_path("/home/alice/projects/foo"),
904 "-home-alice-projects-foo"
905 );
906 }
907
908 #[test]
909 fn test_encode_project_path_unix_trailing_slash() {
910 assert_eq!(encode_project_path("/Users/seven/Loom/"), "-Users-seven-Loom");
911 }
912
913 #[test]
914 fn test_normalize_path() {
915 assert_eq!(normalize_path("S:\\Loom\\"), "s:/loom");
916 assert_eq!(normalize_path("S:/Loom"), "s:/loom");
917 assert_eq!(normalize_path("C:\\Users\\Seven\\"), "c:/users/seven");
918 }
919
920 fn write_jsonl(lines: &[&str]) -> (TempDir, PathBuf) {
921 let tmp = TempDir::new().unwrap();
922 let path = tmp.path().join("abc123.jsonl");
923 let mut f = fs::File::create(&path).unwrap();
924 for l in lines {
925 writeln!(f, "{}", l).unwrap();
926 }
927 (tmp, path)
928 }
929
930 fn assistant_line(id: &str, model: &str, ts: &str, inp: u64, out: u64, cr: u64, cw: u64) -> String {
931 format!(
932 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}}}}}}}"#
933 )
934 }
935
936 #[test]
937 fn parse_deduplicates_repeated_message_ids() {
938 let a1 = assistant_line("msg_01", "claude-opus-4-6", "2026-04-14T08:27:38.895Z", 15, 1463, 16397, 25661);
941 let a1_dup = a1.clone();
942 let a2 = assistant_line("msg_02", "claude-opus-4-6", "2026-04-14T08:34:37.200Z", 10, 401, 42058, 1668);
943 let a2_dup = a2.clone();
944 let a2_dup2 = a2.clone();
945 let (_tmp, path) = write_jsonl(&[&a1, &a1_dup, &a2, &a2_dup, &a2_dup2]);
946
947 let entries = parse_jsonl_entries(&path).unwrap().entries;
948 assert_eq!(entries.len(), 2, "should dedup by message.id");
949 assert_eq!(entries[0].input_tokens, 15);
950 assert_eq!(entries[0].output_tokens, 1463);
951 assert_eq!(entries[1].input_tokens, 10);
952 }
953
954 #[test]
955 fn parse_captures_entrypoint_from_assistant_row() {
956 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}}}"#;
960 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}}}"#;
961 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}}}"#;
962 let (_tmp, path) = write_jsonl(&[headless, interactive, legacy]);
963
964 let entries = parse_jsonl_entries(&path).unwrap().entries;
965 assert_eq!(entries.len(), 3);
966 assert_eq!(entries[0].entrypoint.as_deref(), Some("sdk-cli"));
967 assert_eq!(entries[1].entrypoint.as_deref(), Some("cli"));
968 assert_eq!(entries[2].entrypoint, None);
969 }
970
971 #[test]
972 fn parse_skips_non_assistant_entries() {
973 let lines = vec![
974 r#"{"type":"user","message":{"role":"user","content":"hi"}}"#.to_string(),
975 r#"{"type":"permission-mode"}"#.to_string(),
976 r#"{"type":"file-history-snapshot"}"#.to_string(),
977 r#"{"type":"attachment","timestamp":"2026-04-14T08:27:26.236Z"}"#.to_string(),
978 assistant_line("msg_01", "claude-sonnet-4-6", "2026-04-14T08:28:00.000Z", 100, 200, 0, 0),
979 ];
980 let line_refs: Vec<&str> = lines.iter().map(|s| s.as_str()).collect();
981 let (_tmp, path) = write_jsonl(&line_refs);
982
983 let entries = parse_jsonl_entries(&path).unwrap().entries;
984 assert_eq!(entries.len(), 1);
985 assert_eq!(entries[0].model, "claude-sonnet-4-6");
986 }
987
988 #[test]
989 fn parse_tolerates_malformed_lines_and_empty_lines() {
990 let good = assistant_line("msg_01", "claude-opus-4-6", "2026-04-14T08:27:38.895Z", 5, 10, 0, 0);
991 let lines = vec![
992 "",
993 "not json at all",
994 r#"{"type":"assistant","message":null}"#,
995 r#"{"type":"assistant","message":{"id":null}}"#,
996 &good,
997 " ",
998 r#"{"broken":"trailing"#,
999 ];
1000 let (_tmp, path) = write_jsonl(&lines);
1001
1002 let entries = parse_jsonl_entries(&path).unwrap().entries;
1003 assert_eq!(entries.len(), 1);
1004 }
1005
1006 #[test]
1007 fn parse_extracts_session_id_from_file_stem() {
1008 let line = assistant_line("msg_01", "claude-opus-4-6", "2026-04-14T08:27:38.895Z", 1, 1, 0, 0);
1009 let (_tmp, path) = write_jsonl(&[&line]);
1010 let entries = parse_jsonl_entries(&path).unwrap().entries;
1011 assert_eq!(entries[0].session_id, "abc123");
1012 }
1013
1014 #[test]
1015 fn parse_parses_iso_timestamps() {
1016 let line = assistant_line("msg_01", "claude-opus-4-6", "2026-04-14T08:27:38.895Z", 1, 1, 0, 0);
1017 let (_tmp, path) = write_jsonl(&[&line]);
1018 let entries = parse_jsonl_entries(&path).unwrap().entries;
1019 assert_eq!(entries[0].timestamp_ms, 1_776_155_258_895);
1021 }
1022
1023 #[test]
1024 fn parse_skips_synthetic_model_rows() {
1025 let real = assistant_line("msg_real", "claude-opus-4-6", "2026-04-11T00:27:38.895Z", 15, 100, 0, 0);
1028 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}}}"#;
1029 let (_tmp, path) = write_jsonl(&[&real, synthetic]);
1030 let entries = parse_jsonl_entries(&path).unwrap().entries;
1031 assert_eq!(entries.len(), 1);
1032 assert_eq!(entries[0].model, "claude-opus-4-6");
1033 }
1034
1035 #[test]
1036 fn parse_handles_missing_usage_fields() {
1037 let line = r#"{"type":"assistant","timestamp":"2026-04-14T08:27:38.895Z","message":{"id":"msg_01","model":"claude-opus-4-6"}}"#;
1038 let (_tmp, path) = write_jsonl(&[line]);
1039 let entries = parse_jsonl_entries(&path).unwrap().entries;
1040 assert_eq!(entries.len(), 1);
1041 assert_eq!(entries[0].input_tokens, 0);
1042 assert_eq!(entries[0].cache_read_tokens, 0);
1043 }
1044
1045 #[test]
1046 fn reduce_computes_primary_model_by_mode() {
1047 let e = |m: &str| UsageEntry {
1048 session_id: "s".into(),
1049 timestamp_ms: 0,
1050 model: m.into(),
1051 input_tokens: 1,
1052 output_tokens: 1,
1053 cache_read_tokens: 0,
1054 cache_write_tokens: 0,
1055 tool_uses: Vec::new(),
1056 text_chars: 0,
1057 activity: Activity::General,
1058 entrypoint: None,
1059 };
1060 let entries = vec![
1061 e("claude-opus-4-6"),
1062 e("claude-opus-4-6"),
1063 e("claude-opus-4-6"),
1064 e("claude-sonnet-4-6"),
1065 ];
1066 let u = reduce_session(&entries, "", "sid").unwrap();
1067 assert_eq!(u.model, "claude-opus-4-6");
1068 assert_eq!(u.message_count, 4);
1069 }
1070
1071 #[test]
1072 fn reduce_uses_launched_model_for_context_window() {
1073 let entries = vec![UsageEntry {
1074 session_id: "s".into(),
1075 timestamp_ms: 0,
1076 model: "claude-opus-4-6".into(),
1078 input_tokens: 1000,
1079 output_tokens: 0,
1080 cache_read_tokens: 0,
1081 cache_write_tokens: 0,
1082 tool_uses: Vec::new(),
1083 text_chars: 0,
1084 activity: Activity::General,
1085 entrypoint: None,
1086 }];
1087 let with_1m = reduce_session(&entries, "claude-opus-4-6[1m]", "sid").unwrap();
1088 let without = reduce_session(&entries, "", "sid").unwrap();
1089 assert_eq!(with_1m.context_window_size, 1_000_000);
1090 assert_eq!(without.context_window_size, 200_000);
1091 }
1092
1093 #[test]
1094 fn reduce_context_pct_uses_last_entry_only() {
1095 let mk = |input: u64, cr: u64, cw: u64| UsageEntry {
1097 session_id: "s".into(),
1098 timestamp_ms: 0,
1099 model: "claude-opus-4-6".into(),
1100 input_tokens: input,
1101 output_tokens: 0,
1102 cache_read_tokens: cr,
1103 cache_write_tokens: cw,
1104 tool_uses: Vec::new(),
1105 text_chars: 0,
1106 activity: Activity::General,
1107 entrypoint: None,
1108 };
1109 let entries = vec![
1110 mk(1000, 0, 0),
1111 mk(500, 0, 0),
1112 mk(5, 160_000, 0), ];
1114 let u = reduce_session(&entries, "", "sid").unwrap();
1115 assert_eq!(u.current_context_tokens, 160_005);
1116 assert!((u.context_window_used_pct - 80.0025).abs() < 0.01);
1117 }
1118
1119 #[test]
1120 fn reduce_cache_hit_pct() {
1121 let e = |input: u64, cr: u64, cw: u64| UsageEntry {
1122 session_id: "s".into(),
1123 timestamp_ms: 0,
1124 model: "claude-opus-4-6".into(),
1125 input_tokens: input,
1126 output_tokens: 1,
1127 cache_read_tokens: cr,
1128 cache_write_tokens: cw,
1129 tool_uses: Vec::new(),
1130 text_chars: 0,
1131 activity: Activity::General,
1132 entrypoint: None,
1133 };
1134 let u = reduce_session(&[e(3, 97, 0)], "", "sid").unwrap();
1136 assert!((u.cache_hit_pct - 97.0).abs() < 0.01);
1137
1138 let u = reduce_session(&[e(10, 80, 10)], "", "sid").unwrap();
1140 assert!((u.cache_hit_pct - 80.0).abs() < 0.01);
1141
1142 let u = reduce_session(&[e(0, 0, 0)], "", "sid").unwrap();
1144 assert_eq!(u.cache_hit_pct, 0.0);
1145 }
1146
1147 #[test]
1148 fn reduce_total_context_sent_is_cumulative() {
1149 let mk = |input: u64, cr: u64, cw: u64| UsageEntry {
1150 session_id: "s".into(),
1151 timestamp_ms: 0,
1152 model: "claude-opus-4-6".into(),
1153 input_tokens: input,
1154 output_tokens: 0,
1155 cache_read_tokens: cr,
1156 cache_write_tokens: cw,
1157 tool_uses: Vec::new(),
1158 text_chars: 0,
1159 activity: Activity::General,
1160 entrypoint: None,
1161 };
1162 let u = reduce_session(
1163 &[mk(15, 16_397, 25_661), mk(10, 42_058, 1_668)],
1164 "",
1165 "sid",
1166 )
1167 .unwrap();
1168 assert_eq!(u.total_context_sent, 15 + 16_397 + 25_661 + 10 + 42_058 + 1_668);
1169 }
1170
1171 #[test]
1172 fn reduce_cost_matches_opus_pricing() {
1173 let entries = vec![UsageEntry {
1175 session_id: "s".into(),
1176 timestamp_ms: 0,
1177 model: "claude-opus-4-6".into(),
1178 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(),
1183 text_chars: 0,
1184 activity: Activity::General,
1185 entrypoint: None,
1186 }];
1187 let u = reduce_session(&entries, "", "sid").unwrap();
1188 assert!((u.estimated_cost_usd - 36.75).abs() < 0.001);
1189 }
1190
1191 #[test]
1192 fn reduce_empty_returns_none() {
1193 assert!(reduce_session(&[], "", "sid").is_none());
1194 }
1195
1196 fn assistant_with_content(id: &str, ts: &str, content_json: &str) -> String {
1199 format!(
1200 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}}}}}"#
1201 )
1202 }
1203
1204 #[test]
1205 fn parse_extracts_tool_use_names() {
1206 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"}}]"#;
1207 let line = assistant_with_content("msg_1", "2026-04-14T08:27:38.895Z", content);
1208 let (_tmp, path) = write_jsonl(&[&line]);
1209 let entries = parse_jsonl_entries(&path).unwrap().entries;
1210 assert_eq!(entries.len(), 1);
1211 assert_eq!(entries[0].tool_uses, vec!["Edit".to_string(), "Read".to_string()]);
1212 }
1213
1214 #[test]
1215 fn parse_captures_text_char_count() {
1216 let content = r#"[{"type":"text","text":"Hello world"},{"type":"text","text":"!"}]"#;
1217 let line = assistant_with_content("msg_1", "2026-04-14T08:27:38.895Z", content);
1218 let (_tmp, path) = write_jsonl(&[&line]);
1219 let entries = parse_jsonl_entries(&path).unwrap().entries;
1220 assert_eq!(entries[0].text_chars, 12); }
1222
1223 #[test]
1224 fn parse_handles_missing_content_gracefully() {
1225 let line = assistant_line("msg_1", "claude-opus-4-6", "2026-04-14T08:27:38.895Z", 5, 10, 0, 0);
1228 let (_tmp, path) = write_jsonl(&[&line]);
1229 let entries = parse_jsonl_entries(&path).unwrap().entries;
1230 assert_eq!(entries.len(), 1);
1231 assert!(entries[0].tool_uses.is_empty());
1232 assert_eq!(entries[0].text_chars, 0);
1233 assert_eq!(entries[0].activity, Activity::General);
1234 }
1235
1236 #[test]
1237 fn parse_classifies_edit_plus_debug_as_debugging() {
1238 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();
1240 let content = r#"[{"type":"tool_use","id":"tu_1","name":"Edit","input":{"file_path":"login.rs"}}]"#;
1241 let asst_line = assistant_with_content("msg_1", "2026-04-14T08:27:38.895Z", content);
1242 let (_tmp, path) = write_jsonl(&[&user_line, &asst_line]);
1243 let entries = parse_jsonl_entries(&path).unwrap().entries;
1244 assert_eq!(entries.len(), 1);
1245 assert_eq!(entries[0].activity, Activity::Debugging);
1246 }
1247
1248 #[test]
1249 fn parse_classifies_bash_pytest_as_testing() {
1250 let user_line = r#"{"type":"user","timestamp":"2026-04-14T08:27:30.000Z","message":{"role":"user","content":"run the tests"}}"#.to_string();
1251 let content = r#"[{"type":"tool_use","id":"tu_1","name":"Bash","input":{"command":"pytest tests/unit"}}]"#;
1252 let asst_line = assistant_with_content("msg_1", "2026-04-14T08:27:38.895Z", content);
1253 let (_tmp, path) = write_jsonl(&[&user_line, &asst_line]);
1254 let entries = parse_jsonl_entries(&path).unwrap().entries;
1255 assert_eq!(entries[0].activity, Activity::Testing);
1256 }
1257
1258 #[test]
1259 fn parse_classifies_bash_git_as_gitops() {
1260 let content = r#"[{"type":"tool_use","id":"tu_1","name":"Bash","input":{"command":"git push origin main"}}]"#;
1261 let line = assistant_with_content("msg_1", "2026-04-14T08:27:38.895Z", content);
1262 let (_tmp, path) = write_jsonl(&[&line]);
1263 let entries = parse_jsonl_entries(&path).unwrap().entries;
1264 assert_eq!(entries[0].activity, Activity::GitOps);
1265 }
1266
1267 #[test]
1268 fn parse_tool_result_row_does_not_overwrite_user_text() {
1269 let user_real = r#"{"type":"user","timestamp":"2026-04-14T08:27:30.000Z","message":{"role":"user","content":"refactor the usage store"}}"#.to_string();
1274 let asst1 = assistant_with_content(
1275 "msg_1",
1276 "2026-04-14T08:27:35.000Z",
1277 r#"[{"type":"tool_use","id":"tu_1","name":"Read","input":{"file_path":"store.ts"}}]"#,
1278 );
1279 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();
1280 let asst2 = assistant_with_content(
1281 "msg_2",
1282 "2026-04-14T08:27:40.000Z",
1283 r#"[{"type":"tool_use","id":"tu_2","name":"Edit","input":{"file_path":"store.ts"}}]"#,
1284 );
1285 let (_tmp, path) = write_jsonl(&[&user_real, &asst1, &tool_result, &asst2]);
1286 let entries = parse_jsonl_entries(&path).unwrap().entries;
1287 assert_eq!(entries.len(), 2);
1288 assert_eq!(entries[0].activity, Activity::Exploration);
1290 assert_eq!(entries[1].activity, Activity::Refactor);
1293 }
1294
1295 #[test]
1296 fn parse_classifies_agent_turn_as_delegation() {
1297 let content = r#"[{"type":"tool_use","id":"tu_1","name":"Agent","input":{"description":"explore"}}]"#;
1298 let line = assistant_with_content("msg_1", "2026-04-14T08:27:38.895Z", content);
1299 let (_tmp, path) = write_jsonl(&[&line]);
1300 let entries = parse_jsonl_entries(&path).unwrap().entries;
1301 assert_eq!(entries[0].activity, Activity::Delegation);
1302 }
1303
1304 #[test]
1305 fn parse_pure_text_turn_is_conversation() {
1306 let user_line = r#"{"type":"user","timestamp":"2026-04-14T08:27:30.000Z","message":{"role":"user","content":"thanks"}}"#.to_string();
1307 let content = r#"[{"type":"text","text":"You're welcome — anything else?"}]"#;
1308 let asst_line = assistant_with_content("msg_1", "2026-04-14T08:27:38.895Z", content);
1309 let (_tmp, path) = write_jsonl(&[&user_line, &asst_line]);
1310 let entries = parse_jsonl_entries(&path).unwrap().entries;
1311 assert_eq!(entries[0].activity, Activity::Conversation);
1312 assert!(entries[0].text_chars > 0);
1313 }
1314
1315 #[test]
1316 fn parse_user_array_content_extracts_text_blocks() {
1317 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();
1320 let content = r#"[{"type":"tool_use","id":"tu_1","name":"Edit","input":{}}]"#;
1321 let asst_line = assistant_with_content("msg_1", "2026-04-14T08:27:38.895Z", content);
1322 let (_tmp, path) = write_jsonl(&[&user_line, &asst_line]);
1323 let entries = parse_jsonl_entries(&path).unwrap().entries;
1324 assert_eq!(entries[0].activity, Activity::Debugging);
1325 }
1326
1327 fn assistant_split_row(id: &str, ts: &str, content_json: &str) -> String {
1333 format!(
1334 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}}}}}"#
1335 )
1336 }
1337
1338 #[test]
1339 fn parse_merges_tool_uses_across_split_content_rows() {
1340 let thinking = assistant_split_row(
1346 "msg_01M6AvUVpAFjzSWcXeyGzUwV",
1347 "2026-05-13T09:09:03.500Z",
1348 r#"[{"type":"thinking","thinking":"plan"}]"#,
1349 );
1350 let text = assistant_split_row(
1351 "msg_01M6AvUVpAFjzSWcXeyGzUwV",
1352 "2026-05-13T09:09:03.550Z",
1353 r#"[{"type":"text","text":"Running tools in parallel."}]"#,
1354 );
1355 let tu1 = assistant_split_row(
1356 "msg_01M6AvUVpAFjzSWcXeyGzUwV",
1357 "2026-05-13T09:09:03.600Z",
1358 r#"[{"type":"tool_use","id":"tu_1","name":"mcp__example__list_configs","input":{"area":"rules"}}]"#,
1359 );
1360 let tu2 = assistant_split_row(
1361 "msg_01M6AvUVpAFjzSWcXeyGzUwV",
1362 "2026-05-13T09:09:03.700Z",
1363 r#"[{"type":"tool_use","id":"tu_2","name":"mcp__example__get_report","input":{}}]"#,
1364 );
1365 let tu3 = assistant_split_row(
1366 "msg_01M6AvUVpAFjzSWcXeyGzUwV",
1367 "2026-05-13T09:09:03.800Z",
1368 r#"[{"type":"tool_use","id":"tu_3","name":"Edit","input":{"file_path":"a.rs"}}]"#,
1369 );
1370 let (_tmp, path) = write_jsonl(&[&thinking, &text, &tu1, &tu2, &tu3]);
1371
1372 let entries = parse_jsonl_entries(&path).unwrap().entries;
1373 assert_eq!(entries.len(), 1, "still one entry per message.id");
1374
1375 let e = &entries[0];
1376 assert_eq!(
1377 e.tool_uses,
1378 vec![
1379 "mcp__example__list_configs".to_string(),
1380 "mcp__example__get_report".to_string(),
1381 "Edit".to_string(),
1382 ],
1383 "all tool_use names merged in row order"
1384 );
1385 assert!(e.text_chars > 0, "text from the text row is preserved");
1386
1387 assert_eq!(e.input_tokens, 6);
1391 assert_eq!(e.output_tokens, 746);
1392 assert_eq!(e.cache_read_tokens, 21859);
1393 assert_eq!(e.cache_write_tokens, 33027);
1394 }
1395
1396 #[test]
1397 fn parse_split_row_classifies_against_merged_content() {
1398 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();
1403 let thinking = assistant_split_row(
1404 "msg_split",
1405 "2026-05-13T09:09:01.000Z",
1406 r#"[{"type":"thinking","thinking":"investigating"}]"#,
1407 );
1408 let edit = assistant_split_row(
1409 "msg_split",
1410 "2026-05-13T09:09:02.000Z",
1411 r#"[{"type":"tool_use","id":"tu_1","name":"Edit","input":{"file_path":"auth.rs"}}]"#,
1412 );
1413 let (_tmp, path) = write_jsonl(&[&user, &thinking, &edit]);
1414
1415 let entries = parse_jsonl_entries(&path).unwrap().entries;
1416 assert_eq!(entries.len(), 1);
1417 assert_eq!(entries[0].activity, Activity::Debugging);
1418 assert_eq!(entries[0].tool_uses, vec!["Edit".to_string()]);
1419 }
1420
1421 #[test]
1422 fn parse_split_row_bash_commands_feed_classifier() {
1423 let user = r#"{"type":"user","timestamp":"2026-05-13T09:09:00.000Z","message":{"role":"user","content":"run tests"}}"#.to_string();
1426 let text = assistant_split_row(
1427 "msg_bash",
1428 "2026-05-13T09:09:01.000Z",
1429 r#"[{"type":"text","text":"Running the unit tests."}]"#,
1430 );
1431 let bash = assistant_split_row(
1432 "msg_bash",
1433 "2026-05-13T09:09:02.000Z",
1434 r#"[{"type":"tool_use","id":"tu_1","name":"Bash","input":{"command":"pytest tests/"}}]"#,
1435 );
1436 let (_tmp, path) = write_jsonl(&[&user, &text, &bash]);
1437
1438 let entries = parse_jsonl_entries(&path).unwrap().entries;
1439 assert_eq!(entries[0].activity, Activity::Testing);
1440 }
1441
1442 #[test]
1445 fn parse_extracts_hook_success_events() {
1446 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}}"#;
1447 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}}"#;
1448 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"}}"#;
1450 let asst = assistant_line("msg_01", "claude-opus-4-6", "2026-04-15T18:08:51.000Z", 5, 10, 0, 0);
1451 let (_tmp, path) = write_jsonl(&[hook_line, hook_fail, hook_msg, &asst]);
1452
1453 let parsed = parse_jsonl_entries(&path).unwrap();
1454 assert_eq!(parsed.entries.len(), 1, "assistant entries unaffected");
1455 assert_eq!(parsed.hooks.len(), 2, "two hook_success events");
1456
1457 assert_eq!(parsed.hooks[0].hook_name, "PostToolUse:Edit");
1458 assert_eq!(parsed.hooks[0].hook_event, "PostToolUse");
1459 assert_eq!(parsed.hooks[0].command, "verify-edit.sh");
1460 assert_eq!(parsed.hooks[0].exit_code, 0);
1461 assert_eq!(parsed.hooks[0].duration_ms, 90);
1462
1463 assert_eq!(parsed.hooks[0].tool_use_id.as_deref(), Some("tu_abc"));
1464
1465 assert_eq!(parsed.hooks[1].hook_name, "PreToolUse:Bash");
1466 assert_eq!(parsed.hooks[1].exit_code, 1);
1467 assert_eq!(parsed.hooks[1].duration_ms, 250);
1468 assert_eq!(parsed.hooks[1].tool_use_id.as_deref(), Some("tu_def"));
1469 }
1470
1471 #[test]
1472 fn parse_ignores_attachment_without_hook_data() {
1473 let non_hook = r#"{"type":"attachment","timestamp":"2026-04-14T08:27:26.236Z","attachment":{"type":"file_content","path":"foo.rs"}}"#;
1475 let asst = assistant_line("msg_01", "claude-opus-4-6", "2026-04-14T08:28:00.000Z", 1, 1, 0, 0);
1476 let (_tmp, path) = write_jsonl(&[non_hook, &asst]);
1477
1478 let parsed = parse_jsonl_entries(&path).unwrap();
1479 assert_eq!(parsed.entries.len(), 1);
1480 assert!(parsed.hooks.is_empty());
1481 }
1482
1483 #[test]
1484 fn snake_to_pascal_converts_correctly() {
1485 assert_eq!(snake_to_pascal("stop"), "Stop");
1486 assert_eq!(snake_to_pascal("pre_tool_use"), "PreToolUse");
1487 assert_eq!(snake_to_pascal("post_tool_use"), "PostToolUse");
1488 assert_eq!(snake_to_pascal("session_start"), "SessionStart");
1489 }
1490
1491 #[test]
1492 fn parse_extracts_system_hook_summary_v2() {
1493 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"}"#;
1495 let asst = assistant_line("msg_01", "claude-opus-4-6", "2026-04-25T09:06:13.000Z", 5, 10, 0, 0);
1496 let (_tmp, path) = write_jsonl(&[stop_summary, &asst]);
1497
1498 let parsed = parse_jsonl_entries(&path).unwrap();
1499 assert_eq!(parsed.entries.len(), 1);
1500 assert_eq!(parsed.hooks.len(), 1);
1501
1502 let h = &parsed.hooks[0];
1503 assert_eq!(h.hook_event, "Stop");
1504 assert_eq!(h.command, "Running build checks...");
1505 assert_eq!(h.duration_ms, 5227);
1506 assert_eq!(h.exit_code, 0, "no errors and not prevented = success");
1507 assert_eq!(h.tool_use_id.as_deref(), Some("tu_xyz"));
1508 }
1509
1510 #[test]
1511 fn parse_system_hook_summary_with_errors() {
1512 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"}"#;
1513 let asst = assistant_line("msg_02", "claude-opus-4-6", "2026-04-25T10:00:01.000Z", 1, 1, 0, 0);
1514 let (_tmp, path) = write_jsonl(&[summary, &asst]);
1515
1516 let parsed = parse_jsonl_entries(&path).unwrap();
1517 assert_eq!(parsed.hooks.len(), 1);
1518 assert_eq!(parsed.hooks[0].exit_code, 1, "errors or prevented = failure");
1519 }
1520
1521 #[test]
1522 fn parse_system_hook_summary_multiple_hooks() {
1523 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"}"#;
1524 let asst = assistant_line("msg_03", "claude-opus-4-6", "2026-04-25T11:00:01.000Z", 1, 1, 0, 0);
1525 let (_tmp, path) = write_jsonl(&[summary, &asst]);
1526
1527 let parsed = parse_jsonl_entries(&path).unwrap();
1528 assert_eq!(parsed.hooks.len(), 2);
1529 assert_eq!(parsed.hooks[0].command, "tsc --noEmit");
1530 assert_eq!(parsed.hooks[0].duration_ms, 1200);
1531 assert_eq!(parsed.hooks[1].command, "cargo check");
1532 assert_eq!(parsed.hooks[1].duration_ms, 3500);
1533 assert_eq!(parsed.hooks[0].tool_use_id, parsed.hooks[1].tool_use_id);
1534 }
1535
1536 #[test]
1537 fn hook_name_label_sanitizes_long_multiline_commands() {
1538 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.";
1542 let escaped = serde_json::to_string(long_cmd).unwrap();
1543 let summary = format!(
1544 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"}}"#
1545 );
1546 let asst = assistant_line("msg_goal", "claude-opus-4-6", "2026-04-25T12:00:01.000Z", 1, 1, 0, 0);
1547 let (_tmp, path) = write_jsonl(&[&summary, &asst]);
1548
1549 let parsed = parse_jsonl_entries(&path).unwrap();
1550 assert_eq!(parsed.hooks.len(), 1);
1551 let h = &parsed.hooks[0];
1552
1553 assert_eq!(h.command, long_cmd, "full command preserved on the entry");
1554 assert!(h.hook_name.starts_with("Stop:"), "name still scoped by event");
1555 assert!(h.hook_name.chars().count() <= "Stop:".len() + 65);
1557 assert!(h.hook_name.ends_with('…'), "long commands get ellipsis: {}", h.hook_name);
1558 assert!(!h.hook_name.contains('\n'), "no newlines in bucket key");
1559 }
1560
1561 #[test]
1562 fn hook_name_label_preserves_short_commands() {
1563 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"}"#;
1564 let asst = assistant_line("msg_short", "claude-opus-4-6", "2026-04-25T13:00:01.000Z", 1, 1, 0, 0);
1565 let (_tmp, path) = write_jsonl(&[summary, &asst]);
1566
1567 let parsed = parse_jsonl_entries(&path).unwrap();
1568 assert_eq!(parsed.hooks[0].hook_name, "Stop:cargo check");
1569 }
1570
1571 #[test]
1572 fn parse_handles_both_formats_in_same_file() {
1573 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}}"#;
1575 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"}"#;
1577 let asst = assistant_line("msg_both", "claude-opus-4-6", "2026-04-25T09:07:00.000Z", 1, 1, 0, 0);
1578 let (_tmp, path) = write_jsonl(&[old_hook, new_hook, &asst]);
1579
1580 let parsed = parse_jsonl_entries(&path).unwrap();
1581 assert_eq!(parsed.hooks.len(), 2, "both formats parsed");
1582 assert_eq!(parsed.hooks[0].hook_event, "PostToolUse");
1583 assert_eq!(parsed.hooks[1].hook_event, "Stop");
1584 }
1585}