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 {
20 pub input: f64,
21 pub output: f64,
22 pub cache_read: f64,
23 pub cache_write: f64,
25 pub cache_write_1h: f64,
27}
28
29fn family_version(model: &str, family: &str) -> Option<(u32, u32)> {
43 fn is_date(p: &str) -> bool {
44 p.len() == 8 && p.bytes().all(|b| b.is_ascii_digit())
45 }
46 let model = model.split('[').next().unwrap_or(model);
47 let rest = model.split_once(family)?.1.strip_prefix('-')?;
48 let mut parts = rest.split('-');
49 let head = parts.next()?;
50 if is_date(head) {
54 return None;
55 }
56 let major: u32 = head.parse().ok()?;
57 let minor = match parts.next() {
58 Some(p) if is_date(p) => 0,
59 Some(p) => p.parse().unwrap_or(0),
60 None => 0,
61 };
62 Some((major, minor))
63}
64
65pub fn pricing_for_model(model: &str) -> ModelPricing {
77 if model.contains("fable") {
78 let cache_read = match family_version(model, "fable") {
81 Some(v) if v >= (5, 1) => 0.25,
82 _ => 1.0,
83 };
84 ModelPricing {
85 input: 10.0,
86 output: 50.0,
87 cache_read,
88 cache_write: 12.5,
89 cache_write_1h: 20.0,
90 }
91 } else if matches!(family_version(model, "opus"), Some(v) if v >= (5, 5)) {
92 ModelPricing {
95 input: 4.0,
96 output: 20.0,
97 cache_read: 0.2,
98 cache_write: 5.0,
99 cache_write_1h: 8.0,
100 }
101 } else if model.contains("opus") {
102 ModelPricing {
103 input: 5.0,
104 output: 25.0,
105 cache_read: 0.5,
106 cache_write: 6.25,
107 cache_write_1h: 10.0,
108 }
109 } else if model.contains("haiku") {
110 ModelPricing {
111 input: 1.0,
112 output: 5.0,
113 cache_read: 0.1,
114 cache_write: 1.25,
115 cache_write_1h: 2.0,
116 }
117 } else if matches!(family_version(model, "sonnet"), Some(v) if v >= (5, 0)) {
118 ModelPricing {
119 input: 2.0,
120 output: 10.0,
121 cache_read: 0.2,
122 cache_write: 2.5,
123 cache_write_1h: 4.0,
124 }
125 } else {
126 ModelPricing {
128 input: 3.0,
129 output: 15.0,
130 cache_read: 0.3,
131 cache_write: 3.75,
132 cache_write_1h: 6.0,
133 }
134 }
135}
136
137pub const WEB_SEARCH_COST_PER_REQUEST: f64 = 0.01;
139
140pub fn fast_multiplier_for_model(model: &str) -> f64 {
153 if model.contains("opus-4-8") || model.contains("opus-5") {
154 2.0
155 } else if model.contains("opus-4-7") || model.contains("opus-4-6") {
156 6.0
157 } else {
158 1.0
159 }
160}
161
162pub fn cost_for_entry(entry: &UsageEntry) -> f64 {
170 let p = pricing_for_model(&entry.model);
171 let cw_1h = entry.cache_write_1h_tokens.min(entry.cache_write_tokens);
172 let cw_5m = entry.cache_write_tokens - cw_1h;
173 let token_cost = entry.input_tokens as f64 * p.input
174 + entry.output_tokens as f64 * p.output
175 + entry.cache_read_tokens as f64 * p.cache_read
176 + cw_5m as f64 * p.cache_write
177 + cw_1h as f64 * p.cache_write_1h;
178 let fast = if entry.speed.as_deref() == Some("fast") {
179 fast_multiplier_for_model(&entry.model)
180 } else {
181 1.0
182 };
183 (token_cost * fast) / 1_000_000.0
184 + entry.web_search_requests as f64 * WEB_SEARCH_COST_PER_REQUEST
185}
186
187pub fn context_window_for_model(model: &str) -> u64 {
188 if model.contains("[1m]") || model.contains("fable") {
190 1_000_000
191 } else {
192 200_000
193 }
194}
195
196#[derive(Debug, Clone, Serialize, Deserialize)]
212#[serde(rename_all = "camelCase")]
213pub struct UsageEntry {
214 pub session_id: String,
215 pub timestamp_ms: u64,
217 pub model: String,
218 pub input_tokens: u64,
219 pub output_tokens: u64,
220 pub cache_read_tokens: u64,
221 pub cache_write_tokens: u64,
226 #[serde(default)]
230 pub cache_write_1h_tokens: u64,
231 #[serde(default)]
234 pub web_search_requests: u64,
235 #[serde(default)]
239 pub speed: Option<String>,
240 pub tool_uses: Vec<String>,
244 pub text_chars: u32,
247 pub activity: Activity,
250 pub entrypoint: Option<String>,
257}
258
259#[derive(Debug, Clone, Serialize, Deserialize)]
262#[serde(rename_all = "camelCase")]
263pub struct HookExecution {
264 pub session_id: String,
265 pub timestamp_ms: u64,
266 pub hook_event: String,
268 pub hook_name: String,
270 pub command: String,
272 pub exit_code: i32,
273 pub duration_ms: u64,
274 pub tool_use_id: Option<String>,
276}
277
278#[derive(Default)]
280pub struct ParsedJsonl {
281 pub entries: Vec<UsageEntry>,
282 pub hooks: Vec<HookExecution>,
283}
284
285#[derive(Debug, Clone, Serialize, Default)]
287#[serde(rename_all = "camelCase")]
288pub struct SessionUsage {
289 pub model: String,
290 pub input_tokens: u64,
291 pub output_tokens: u64,
292 pub cache_read_tokens: u64,
293 pub cache_write_tokens: u64,
294 pub total_tokens: u64,
295 pub estimated_cost_usd: f64,
296 pub context_window_size: u64,
297 pub context_window_used_pct: f64,
298 pub current_context_tokens: u64,
300 pub total_context_sent: u64,
302 pub cache_hit_pct: f64,
304 pub message_count: u64,
305 pub session_id: String,
306}
307
308#[derive(Deserialize)]
310#[serde(rename_all = "camelCase")]
311struct JsonlRaw {
312 #[serde(rename = "type")]
313 entry_type: Option<String>,
314 timestamp: Option<String>,
315 message: Option<MessageData>,
316 attachment: Option<AttachmentData>,
317 subtype: Option<String>,
319 #[allow(dead_code)]
320 hook_count: Option<usize>,
321 hook_infos: Option<Vec<HookInfoEntry>>,
322 hook_errors: Option<Vec<serde_json::Value>>,
323 prevented_continuation: Option<bool>,
324 #[serde(rename = "toolUseID")]
325 tool_use_id: Option<String>,
326 entrypoint: Option<String>,
329}
330
331#[derive(Deserialize)]
332struct MessageData {
333 id: Option<String>,
334 model: Option<String>,
335 usage: Option<UsageData>,
336 #[serde(default)]
341 content: Option<serde_json::Value>,
342}
343
344#[derive(Deserialize, Default)]
345struct UsageData {
346 input_tokens: Option<u64>,
347 output_tokens: Option<u64>,
348 cache_read_input_tokens: Option<u64>,
349 cache_creation_input_tokens: Option<u64>,
352 cache_creation: Option<CacheCreation>,
354 server_tool_use: Option<ServerToolUse>,
356 speed: Option<String>,
358}
359
360#[derive(Deserialize, Default)]
362struct CacheCreation {
363 ephemeral_5m_input_tokens: Option<u64>,
364 ephemeral_1h_input_tokens: Option<u64>,
365}
366
367#[derive(Deserialize, Default)]
370struct ServerToolUse {
371 web_search_requests: Option<u64>,
372}
373
374#[derive(Deserialize)]
376#[serde(rename_all = "camelCase")]
377struct HookInfoEntry {
378 command: Option<String>,
379 duration_ms: Option<u64>,
380}
381
382#[derive(Deserialize)]
384#[serde(rename_all = "camelCase")]
385struct AttachmentData {
386 #[serde(rename = "type")]
387 attachment_type: Option<String>,
388 hook_name: Option<String>,
389 hook_event: Option<String>,
390 command: Option<String>,
391 exit_code: Option<i32>,
392 duration_ms: Option<u64>,
393 #[serde(rename = "toolUseID")]
394 tool_use_id: Option<String>,
395}
396
397#[derive(Deserialize)]
399#[serde(rename_all = "camelCase")]
400struct ActiveSession {
401 session_id: String,
402 cwd: String,
403 started_at: u64,
404}
405
406use super::MAX_JSONL_BYTES;
413
414pub fn encode_project_path(project_path: &str) -> String {
424 let normalized = project_path.replace('/', "\\");
425 let mut result = String::new();
426 let mut chars = normalized.chars().peekable();
427
428 while let Some(ch) = chars.next() {
429 if ch == ':' {
430 if chars.peek() == Some(&'\\') {
431 chars.next();
432 result.push_str("--");
433 } else {
434 result.push('-');
435 }
436 } else if ch == '\\' {
437 result.push('-');
438 } else {
439 result.push(ch);
440 }
441 }
442
443 while result.ends_with('-') {
444 result.pop();
445 }
446
447 result
448}
449
450struct AssistantContent {
452 tool_uses: Vec<String>,
453 bash_cmds: Vec<String>,
454 text_chars: u32,
455}
456
457fn extract_assistant_content(content: Option<&serde_json::Value>) -> AssistantContent {
461 let mut out = AssistantContent {
462 tool_uses: Vec::new(),
463 bash_cmds: Vec::new(),
464 text_chars: 0,
465 };
466 let arr = match content.and_then(|v| v.as_array()) {
467 Some(a) => a,
468 None => return out,
469 };
470
471 for block in arr {
472 let block_type = match block.get("type").and_then(|v| v.as_str()) {
473 Some(t) => t,
474 None => continue,
475 };
476 match block_type {
477 "tool_use" => {
478 if let Some(name) = block.get("name").and_then(|v| v.as_str()) {
479 if matches!(name, "Bash" | "BashTool" | "PowerShellTool") {
482 if let Some(cmd) = block
483 .get("input")
484 .and_then(|i| i.get("command"))
485 .and_then(|c| c.as_str())
486 {
487 out.bash_cmds.push(cmd.to_string());
488 }
489 }
490 out.tool_uses.push(name.to_string());
491 }
492 }
493 "text" => {
494 if let Some(text) = block.get("text").and_then(|v| v.as_str()) {
495 out.text_chars = out
498 .text_chars
499 .saturating_add(text.chars().count() as u32);
500 }
501 }
502 _ => {}
503 }
504 }
505
506 out
507}
508
509fn sanitize_hook_command_label(cmd: &str) -> String {
528 const MAX_LEN: usize = 64;
529 let first_line = cmd.lines().next().unwrap_or("").trim();
530 let collapsed: String = first_line.split_whitespace().collect::<Vec<_>>().join(" ");
531 if collapsed.chars().count() <= MAX_LEN {
532 return collapsed;
533 }
534 let truncated: String = collapsed.chars().take(MAX_LEN).collect();
535 format!("{truncated}…")
536}
537
538fn snake_to_pascal(s: &str) -> String {
540 s.split('_')
541 .filter(|w| !w.is_empty())
542 .map(|w| {
543 let mut c = w.chars();
544 match c.next() {
545 Some(first) => {
546 let mut out = first.to_uppercase().to_string();
547 out.extend(c);
548 out
549 }
550 None => String::new(),
551 }
552 })
553 .collect()
554}
555
556fn extract_user_text(content: &serde_json::Value) -> Option<String> {
559 match content {
560 serde_json::Value::String(s) => {
561 if s.is_empty() {
562 None
563 } else {
564 Some(s.clone())
565 }
566 }
567 serde_json::Value::Array(arr) => {
568 let mut parts: Vec<&str> = Vec::new();
569 for item in arr {
570 let t = item.get("type").and_then(|v| v.as_str()).unwrap_or("");
571 if t == "text" {
572 if let Some(text) = item.get("text").and_then(|v| v.as_str()) {
573 if !text.is_empty() {
574 parts.push(text);
575 }
576 }
577 }
578 }
579 if parts.is_empty() {
580 None
581 } else {
582 Some(parts.join(" "))
583 }
584 }
585 _ => None,
586 }
587}
588
589struct EntryAccumulator {
592 out_idx: usize,
593 bash_cmds: Vec<String>,
594 user_text_at_first: Option<String>,
595}
596
597pub fn parse_jsonl_entries(path: &Path) -> std::io::Result<ParsedJsonl> {
621 let meta = fs::metadata(path)?;
622 if meta.len() > MAX_JSONL_BYTES {
623 tracing::warn!(
624 path = %path.display(),
625 size = meta.len(),
626 "skipping JSONL larger than {}MB",
627 MAX_JSONL_BYTES / 1024 / 1024
628 );
629 return Ok(ParsedJsonl::default());
630 }
631
632 let file = fs::File::open(path)?;
633 let reader = BufReader::new(file);
634
635 let session_id = path
636 .file_stem()
637 .and_then(|s| s.to_str())
638 .unwrap_or("")
639 .to_string();
640
641 let mut accums: HashMap<String, EntryAccumulator> = HashMap::new();
642 let mut out: Vec<UsageEntry> = Vec::new();
643 let mut hooks: Vec<HookExecution> = Vec::new();
644 let mut last_user_text: Option<String> = None;
645
646 for line in reader.lines() {
647 let line = match line {
648 Ok(l) => l,
649 Err(_) => continue,
650 };
651 let line = line.trim();
652 if line.is_empty() {
653 continue;
654 }
655
656 let raw: JsonlRaw = match serde_json::from_str(line) {
657 Ok(r) => r,
658 Err(_) => continue,
659 };
660
661 let entry_type = raw.entry_type.as_deref();
662
663 if entry_type == Some("user") {
666 if let Some(msg) = raw.message.as_ref() {
667 if let Some(content) = msg.content.as_ref() {
668 if let Some(text) = extract_user_text(content) {
669 last_user_text = Some(normalize_user_text(&text));
670 }
671 }
672 }
673 continue;
674 }
675
676 if entry_type == Some("attachment") {
678 if let Some(att) = raw.attachment {
679 if att.attachment_type.as_deref() == Some("hook_success") {
680 let timestamp_ms = raw
681 .timestamp
682 .as_deref()
683 .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
684 .map(|dt| dt.timestamp_millis().max(0) as u64)
685 .unwrap_or(0);
686 hooks.push(HookExecution {
687 session_id: session_id.clone(),
688 timestamp_ms,
689 hook_event: att.hook_event.unwrap_or_default(),
690 hook_name: att.hook_name.unwrap_or_default(),
691 command: att.command.unwrap_or_default(),
692 exit_code: att.exit_code.unwrap_or(0),
693 duration_ms: att.duration_ms.unwrap_or(0),
694 tool_use_id: att.tool_use_id,
695 });
696 }
697 }
698 continue;
699 }
700
701 if entry_type == Some("system") {
704 if let Some(subtype) = raw.subtype.as_deref() {
705 if let Some(event_snake) = subtype.strip_suffix("_hook_summary") {
706 let hook_event = snake_to_pascal(event_snake);
707 let timestamp_ms = raw
708 .timestamp
709 .as_deref()
710 .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
711 .map(|dt| dt.timestamp_millis().max(0) as u64)
712 .unwrap_or(0);
713 let had_errors = raw
714 .hook_errors
715 .as_ref()
716 .is_some_and(|e| !e.is_empty());
717 let prevented = raw.prevented_continuation.unwrap_or(false);
718 if let Some(infos) = raw.hook_infos {
719 for info in infos {
720 let cmd = info.command.unwrap_or_default();
721 let hook_name =
722 format!("{}:{}", hook_event, sanitize_hook_command_label(&cmd));
723 hooks.push(HookExecution {
724 session_id: session_id.clone(),
725 timestamp_ms,
726 hook_event: hook_event.clone(),
727 hook_name,
728 command: cmd,
729 exit_code: if had_errors || prevented { 1 } else { 0 },
730 duration_ms: info.duration_ms.unwrap_or(0),
731 tool_use_id: raw.tool_use_id.clone(),
732 });
733 }
734 }
735 }
736 }
737 continue;
738 }
739
740 if entry_type != Some("assistant") {
741 continue;
742 }
743
744 let msg = match raw.message {
745 Some(m) => m,
746 None => continue,
747 };
748 let mid = match msg.id {
749 Some(id) => id,
750 None => continue,
751 };
752
753 let mut content_parsed = extract_assistant_content(msg.content.as_ref());
754
755 if let Some(acc) = accums.get_mut(&mid) {
758 let entry = &mut out[acc.out_idx];
759 entry
760 .tool_uses
761 .append(&mut content_parsed.tool_uses);
762 entry.text_chars = entry
763 .text_chars
764 .saturating_add(content_parsed.text_chars);
765 acc.bash_cmds.append(&mut content_parsed.bash_cmds);
766 continue;
767 }
768
769 let model = msg.model.unwrap_or_default();
775 if model == "<synthetic>" {
776 continue;
777 }
778
779 let usage = msg.usage.unwrap_or_default();
780 let timestamp_ms = raw
781 .timestamp
782 .as_deref()
783 .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
784 .map(|dt| dt.timestamp_millis().max(0) as u64)
785 .unwrap_or(0);
786
787 let cw_flat = usage.cache_creation_input_tokens.unwrap_or(0);
791 let (eph_5m, eph_1h) = usage
792 .cache_creation
793 .as_ref()
794 .map(|c| {
795 (
796 c.ephemeral_5m_input_tokens.unwrap_or(0),
797 c.ephemeral_1h_input_tokens.unwrap_or(0),
798 )
799 })
800 .unwrap_or((0, 0));
801 let cache_write_tokens = cw_flat.max(eph_5m + eph_1h);
802 let cache_write_1h_tokens = eph_1h.min(cache_write_tokens);
803 let web_search_requests = usage
804 .server_tool_use
805 .as_ref()
806 .map(|s| s.web_search_requests.unwrap_or(0))
807 .unwrap_or(0);
808 let speed = usage.speed.clone();
809
810 accums.insert(
814 mid.clone(),
815 EntryAccumulator {
816 out_idx: out.len(),
817 bash_cmds: std::mem::take(&mut content_parsed.bash_cmds),
818 user_text_at_first: last_user_text.clone(),
819 },
820 );
821 out.push(UsageEntry {
822 session_id: session_id.clone(),
823 timestamp_ms,
824 model,
825 input_tokens: usage.input_tokens.unwrap_or(0),
826 output_tokens: usage.output_tokens.unwrap_or(0),
827 cache_read_tokens: usage.cache_read_input_tokens.unwrap_or(0),
828 cache_write_tokens,
829 cache_write_1h_tokens,
830 web_search_requests,
831 speed,
832 tool_uses: content_parsed.tool_uses,
833 text_chars: content_parsed.text_chars,
834 activity: Activity::General,
835 entrypoint: raw.entrypoint.clone(),
836 });
837 }
838
839 for acc in accums.values() {
842 let entry = &mut out[acc.out_idx];
843 let tool_refs: Vec<&str> = entry.tool_uses.iter().map(|s| s.as_str()).collect();
844 let bash_refs: Vec<&str> = acc.bash_cmds.iter().map(|s| s.as_str()).collect();
845 entry.activity = classify(
846 &tool_refs,
847 &bash_refs,
848 acc.user_text_at_first.as_deref(),
849 entry.text_chars,
850 );
851 }
852
853 Ok(ParsedJsonl {
854 entries: out,
855 hooks,
856 })
857}
858
859pub fn find_current_session_jsonl(
863 config_dir: &str,
864 project_path: &str,
865 spawned_at: SystemTime,
866) -> Option<PathBuf> {
867 let config = Path::new(config_dir);
868 let encoded = encode_project_path(project_path);
869 let projects_dir = config.join("projects").join(&encoded);
870
871 if !projects_dir.exists() {
872 return None;
873 }
874
875 let sessions_dir = config.join("sessions");
878 if sessions_dir.exists() {
879 if let Some(session_id) = find_active_session_id(&sessions_dir, project_path, spawned_at) {
880 let jsonl_path = projects_dir.join(format!("{}.jsonl", session_id));
881 if jsonl_path.exists() {
882 return Some(jsonl_path);
883 }
884 }
885 }
886
887 let mut candidates: Vec<(PathBuf, SystemTime)> = Vec::new();
892
893 if let Ok(entries) = fs::read_dir(&projects_dir) {
894 for entry in entries.flatten() {
895 let path = entry.path();
896 if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
897 continue;
898 }
899 if let Ok(meta) = path.metadata() {
900 let created = meta.created().unwrap_or(SystemTime::UNIX_EPOCH);
901 if created >= spawned_at {
902 candidates.push((path, created));
903 }
904 }
905 }
906 }
907
908 candidates.sort_by_key(|a| a.1);
910 candidates.into_iter().next().map(|(p, _)| p)
911}
912
913fn find_active_session_id(
916 sessions_dir: &Path,
917 project_path: &str,
918 spawned_at: SystemTime,
919) -> Option<String> {
920 let spawned_ms = spawned_at
921 .duration_since(SystemTime::UNIX_EPOCH)
922 .ok()?
923 .as_millis() as u64;
924
925 let threshold_ms = spawned_ms.saturating_sub(10_000);
927
928 let normalized_project = normalize_path(project_path);
929
930 let mut best: Option<(String, u64)> = None;
931
932 if let Ok(entries) = fs::read_dir(sessions_dir) {
933 for entry in entries.flatten() {
934 let path = entry.path();
935 if path.extension().and_then(|e| e.to_str()) != Some("json") {
936 continue;
937 }
938 if let Ok(content) = fs::read_to_string(&path) {
939 if let Ok(session) = serde_json::from_str::<ActiveSession>(&content) {
940 let session_cwd = normalize_path(&session.cwd);
941 if session_cwd == normalized_project && session.started_at >= threshold_ms {
942 if best.as_ref().is_none_or(|(_, t)| session.started_at < *t) {
948 best = Some((session.session_id, session.started_at));
949 }
950 }
951 }
952 }
953 }
954 }
955
956 best.map(|(id, _)| id)
957}
958
959fn normalize_path(p: &str) -> String {
961 p.replace('\\', "/")
962 .trim_end_matches('/')
963 .to_lowercase()
964}
965
966pub fn reduce_session(
975 entries: &[UsageEntry],
976 launched_model: &str,
977 session_id_hint: &str,
978) -> Option<SessionUsage> {
979 if entries.is_empty() {
980 return None;
981 }
982
983 let mut input_tokens = 0u64;
984 let mut output_tokens = 0u64;
985 let mut cache_read = 0u64;
986 let mut cache_write = 0u64;
987 let mut model_counts: HashMap<&str, u64> = HashMap::new();
988
989 for e in entries {
990 input_tokens += e.input_tokens;
991 output_tokens += e.output_tokens;
992 cache_read += e.cache_read_tokens;
993 cache_write += e.cache_write_tokens;
994 if !e.model.is_empty() {
995 *model_counts.entry(e.model.as_str()).or_insert(0) += 1;
996 }
997 }
998
999 let primary_model = model_counts
1000 .into_iter()
1001 .max_by_key(|(_, c)| *c)
1002 .map(|(m, _)| m.to_string())
1003 .unwrap_or_else(|| {
1004 entries
1005 .last()
1006 .map(|e| e.model.clone())
1007 .unwrap_or_default()
1008 });
1009
1010 let estimated_cost: f64 = entries.iter().map(cost_for_entry).sum();
1014
1015 let context_model = if launched_model.is_empty() {
1016 &primary_model
1017 } else {
1018 launched_model
1019 };
1020 let context_window = context_window_for_model(context_model);
1021
1022 let last = entries.last().unwrap();
1023 let last_context_used =
1024 last.input_tokens + last.cache_read_tokens + last.cache_write_tokens;
1025 let context_pct = if context_window > 0 {
1026 (last_context_used as f64 / context_window as f64 * 100.0).min(100.0)
1027 } else {
1028 0.0
1029 };
1030
1031 let total = input_tokens + output_tokens + cache_read + cache_write;
1032 let total_context_sent = input_tokens + cache_read + cache_write;
1033
1034 let cache_denom = input_tokens + cache_read + cache_write;
1039 let cache_hit_pct = if cache_denom > 0 {
1040 (cache_read as f64 / cache_denom as f64) * 100.0
1041 } else {
1042 0.0
1043 };
1044
1045 let session_id = entries
1046 .first()
1047 .map(|e| e.session_id.clone())
1048 .filter(|s| !s.is_empty())
1049 .unwrap_or_else(|| session_id_hint.to_string());
1050
1051 Some(SessionUsage {
1052 model: primary_model,
1053 input_tokens,
1054 output_tokens,
1055 cache_read_tokens: cache_read,
1056 cache_write_tokens: cache_write,
1057 total_tokens: total,
1058 estimated_cost_usd: estimated_cost,
1059 context_window_size: context_window,
1060 context_window_used_pct: context_pct,
1061 current_context_tokens: last_context_used,
1062 total_context_sent,
1063 cache_hit_pct,
1064 message_count: entries.len() as u64,
1065 session_id,
1066 })
1067}
1068
1069#[cfg(test)]
1070mod tests {
1071 use super::*;
1072 use std::io::Write;
1073 use tempfile::TempDir;
1074
1075 #[test]
1076 fn test_encode_project_path_drive_root() {
1077 assert_eq!(encode_project_path("S:\\Loom"), "S--Loom");
1078 }
1079
1080 #[test]
1081 fn test_encode_project_path_deep() {
1082 assert_eq!(
1083 encode_project_path("C:\\Users\\Seven\\Desktop\\SF\\project"),
1084 "C--Users-Seven-Desktop-SF-project"
1085 );
1086 }
1087
1088 #[test]
1089 fn test_encode_project_path_forward_slashes() {
1090 assert_eq!(encode_project_path("S:/Loom"), "S--Loom");
1091 }
1092
1093 #[test]
1094 fn test_encode_project_path_trailing_slash() {
1095 assert_eq!(encode_project_path("S:\\Loom\\"), "S--Loom");
1096 }
1097
1098 #[test]
1099 fn test_encode_project_path_macos() {
1100 assert_eq!(
1101 encode_project_path("/Users/seven/Loom"),
1102 "-Users-seven-Loom"
1103 );
1104 }
1105
1106 #[test]
1107 fn test_encode_project_path_linux() {
1108 assert_eq!(
1109 encode_project_path("/home/alice/projects/foo"),
1110 "-home-alice-projects-foo"
1111 );
1112 }
1113
1114 #[test]
1115 fn test_encode_project_path_unix_trailing_slash() {
1116 assert_eq!(encode_project_path("/Users/seven/Loom/"), "-Users-seven-Loom");
1117 }
1118
1119 #[test]
1120 fn test_normalize_path() {
1121 assert_eq!(normalize_path("S:\\Loom\\"), "s:/loom");
1122 assert_eq!(normalize_path("S:/Loom"), "s:/loom");
1123 assert_eq!(normalize_path("C:\\Users\\Seven\\"), "c:/users/seven");
1124 }
1125
1126 fn write_jsonl(lines: &[&str]) -> (TempDir, PathBuf) {
1127 let tmp = TempDir::new().unwrap();
1128 let path = tmp.path().join("abc123.jsonl");
1129 let mut f = fs::File::create(&path).unwrap();
1130 for l in lines {
1131 writeln!(f, "{}", l).unwrap();
1132 }
1133 (tmp, path)
1134 }
1135
1136 fn assistant_line(id: &str, model: &str, ts: &str, inp: u64, out: u64, cr: u64, cw: u64) -> String {
1137 format!(
1138 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}}}}}}}"#
1139 )
1140 }
1141
1142 #[test]
1143 fn parse_deduplicates_repeated_message_ids() {
1144 let a1 = assistant_line("msg_01", "claude-opus-4-6", "2026-04-14T08:27:38.895Z", 15, 1463, 16397, 25661);
1147 let a1_dup = a1.clone();
1148 let a2 = assistant_line("msg_02", "claude-opus-4-6", "2026-04-14T08:34:37.200Z", 10, 401, 42058, 1668);
1149 let a2_dup = a2.clone();
1150 let a2_dup2 = a2.clone();
1151 let (_tmp, path) = write_jsonl(&[&a1, &a1_dup, &a2, &a2_dup, &a2_dup2]);
1152
1153 let entries = parse_jsonl_entries(&path).unwrap().entries;
1154 assert_eq!(entries.len(), 2, "should dedup by message.id");
1155 assert_eq!(entries[0].input_tokens, 15);
1156 assert_eq!(entries[0].output_tokens, 1463);
1157 assert_eq!(entries[1].input_tokens, 10);
1158 }
1159
1160 #[test]
1161 fn parse_captures_entrypoint_from_assistant_row() {
1162 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}}}"#;
1166 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}}}"#;
1167 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}}}"#;
1168 let (_tmp, path) = write_jsonl(&[headless, interactive, legacy]);
1169
1170 let entries = parse_jsonl_entries(&path).unwrap().entries;
1171 assert_eq!(entries.len(), 3);
1172 assert_eq!(entries[0].entrypoint.as_deref(), Some("sdk-cli"));
1173 assert_eq!(entries[1].entrypoint.as_deref(), Some("cli"));
1174 assert_eq!(entries[2].entrypoint, None);
1175 }
1176
1177 #[test]
1178 fn parse_skips_non_assistant_entries() {
1179 let lines = [
1180 r#"{"type":"user","message":{"role":"user","content":"hi"}}"#.to_string(),
1181 r#"{"type":"permission-mode"}"#.to_string(),
1182 r#"{"type":"file-history-snapshot"}"#.to_string(),
1183 r#"{"type":"attachment","timestamp":"2026-04-14T08:27:26.236Z"}"#.to_string(),
1184 assistant_line("msg_01", "claude-sonnet-4-6", "2026-04-14T08:28:00.000Z", 100, 200, 0, 0),
1185 ];
1186 let line_refs: Vec<&str> = lines.iter().map(|s| s.as_str()).collect();
1187 let (_tmp, path) = write_jsonl(&line_refs);
1188
1189 let entries = parse_jsonl_entries(&path).unwrap().entries;
1190 assert_eq!(entries.len(), 1);
1191 assert_eq!(entries[0].model, "claude-sonnet-4-6");
1192 }
1193
1194 #[test]
1195 fn parse_tolerates_malformed_lines_and_empty_lines() {
1196 let good = assistant_line("msg_01", "claude-opus-4-6", "2026-04-14T08:27:38.895Z", 5, 10, 0, 0);
1197 let lines = vec![
1198 "",
1199 "not json at all",
1200 r#"{"type":"assistant","message":null}"#,
1201 r#"{"type":"assistant","message":{"id":null}}"#,
1202 &good,
1203 " ",
1204 r#"{"broken":"trailing"#,
1205 ];
1206 let (_tmp, path) = write_jsonl(&lines);
1207
1208 let entries = parse_jsonl_entries(&path).unwrap().entries;
1209 assert_eq!(entries.len(), 1);
1210 }
1211
1212 #[test]
1213 fn parse_extracts_session_id_from_file_stem() {
1214 let line = assistant_line("msg_01", "claude-opus-4-6", "2026-04-14T08:27:38.895Z", 1, 1, 0, 0);
1215 let (_tmp, path) = write_jsonl(&[&line]);
1216 let entries = parse_jsonl_entries(&path).unwrap().entries;
1217 assert_eq!(entries[0].session_id, "abc123");
1218 }
1219
1220 #[test]
1221 fn parse_parses_iso_timestamps() {
1222 let line = assistant_line("msg_01", "claude-opus-4-6", "2026-04-14T08:27:38.895Z", 1, 1, 0, 0);
1223 let (_tmp, path) = write_jsonl(&[&line]);
1224 let entries = parse_jsonl_entries(&path).unwrap().entries;
1225 assert_eq!(entries[0].timestamp_ms, 1_776_155_258_895);
1227 }
1228
1229 #[test]
1230 fn parse_skips_synthetic_model_rows() {
1231 let real = assistant_line("msg_real", "claude-opus-4-6", "2026-04-11T00:27:38.895Z", 15, 100, 0, 0);
1234 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}}}"#;
1235 let (_tmp, path) = write_jsonl(&[&real, synthetic]);
1236 let entries = parse_jsonl_entries(&path).unwrap().entries;
1237 assert_eq!(entries.len(), 1);
1238 assert_eq!(entries[0].model, "claude-opus-4-6");
1239 }
1240
1241 #[test]
1242 fn parse_handles_missing_usage_fields() {
1243 let line = r#"{"type":"assistant","timestamp":"2026-04-14T08:27:38.895Z","message":{"id":"msg_01","model":"claude-opus-4-6"}}"#;
1244 let (_tmp, path) = write_jsonl(&[line]);
1245 let entries = parse_jsonl_entries(&path).unwrap().entries;
1246 assert_eq!(entries.len(), 1);
1247 assert_eq!(entries[0].input_tokens, 0);
1248 assert_eq!(entries[0].cache_read_tokens, 0);
1249 }
1250
1251 #[test]
1252 fn reduce_computes_primary_model_by_mode() {
1253 let e = |m: &str| UsageEntry {
1254 session_id: "s".into(),
1255 timestamp_ms: 0,
1256 model: m.into(),
1257 input_tokens: 1,
1258 output_tokens: 1,
1259 cache_read_tokens: 0,
1260 cache_write_tokens: 0,
1261 cache_write_1h_tokens: 0,
1262 web_search_requests: 0,
1263 speed: None,
1264 tool_uses: Vec::new(),
1265 text_chars: 0,
1266 activity: Activity::General,
1267 entrypoint: None,
1268 };
1269 let entries = vec![
1270 e("claude-opus-4-6"),
1271 e("claude-opus-4-6"),
1272 e("claude-opus-4-6"),
1273 e("claude-sonnet-4-6"),
1274 ];
1275 let u = reduce_session(&entries, "", "sid").unwrap();
1276 assert_eq!(u.model, "claude-opus-4-6");
1277 assert_eq!(u.message_count, 4);
1278 }
1279
1280 #[test]
1281 fn reduce_uses_launched_model_for_context_window() {
1282 let entries = vec![UsageEntry {
1283 session_id: "s".into(),
1284 timestamp_ms: 0,
1285 model: "claude-opus-4-6".into(),
1287 input_tokens: 1000,
1288 output_tokens: 0,
1289 cache_read_tokens: 0,
1290 cache_write_tokens: 0,
1291 cache_write_1h_tokens: 0,
1292 web_search_requests: 0,
1293 speed: None,
1294 tool_uses: Vec::new(),
1295 text_chars: 0,
1296 activity: Activity::General,
1297 entrypoint: None,
1298 }];
1299 let with_1m = reduce_session(&entries, "claude-opus-4-6[1m]", "sid").unwrap();
1300 let without = reduce_session(&entries, "", "sid").unwrap();
1301 assert_eq!(with_1m.context_window_size, 1_000_000);
1302 assert_eq!(without.context_window_size, 200_000);
1303 }
1304
1305 #[test]
1306 fn reduce_context_pct_uses_last_entry_only() {
1307 let mk = |input: u64, cr: u64, cw: u64| UsageEntry {
1309 session_id: "s".into(),
1310 timestamp_ms: 0,
1311 model: "claude-opus-4-6".into(),
1312 input_tokens: input,
1313 output_tokens: 0,
1314 cache_read_tokens: cr,
1315 cache_write_tokens: cw,
1316 cache_write_1h_tokens: 0,
1317 web_search_requests: 0,
1318 speed: None,
1319 tool_uses: Vec::new(),
1320 text_chars: 0,
1321 activity: Activity::General,
1322 entrypoint: None,
1323 };
1324 let entries = vec![
1325 mk(1000, 0, 0),
1326 mk(500, 0, 0),
1327 mk(5, 160_000, 0), ];
1329 let u = reduce_session(&entries, "", "sid").unwrap();
1330 assert_eq!(u.current_context_tokens, 160_005);
1331 assert!((u.context_window_used_pct - 80.0025).abs() < 0.01);
1332 }
1333
1334 #[test]
1335 fn reduce_cache_hit_pct() {
1336 let e = |input: u64, cr: u64, cw: u64| UsageEntry {
1337 session_id: "s".into(),
1338 timestamp_ms: 0,
1339 model: "claude-opus-4-6".into(),
1340 input_tokens: input,
1341 output_tokens: 1,
1342 cache_read_tokens: cr,
1343 cache_write_tokens: cw,
1344 cache_write_1h_tokens: 0,
1345 web_search_requests: 0,
1346 speed: None,
1347 tool_uses: Vec::new(),
1348 text_chars: 0,
1349 activity: Activity::General,
1350 entrypoint: None,
1351 };
1352 let u = reduce_session(&[e(3, 97, 0)], "", "sid").unwrap();
1354 assert!((u.cache_hit_pct - 97.0).abs() < 0.01);
1355
1356 let u = reduce_session(&[e(10, 80, 10)], "", "sid").unwrap();
1358 assert!((u.cache_hit_pct - 80.0).abs() < 0.01);
1359
1360 let u = reduce_session(&[e(0, 0, 0)], "", "sid").unwrap();
1362 assert_eq!(u.cache_hit_pct, 0.0);
1363 }
1364
1365 #[test]
1366 fn reduce_total_context_sent_is_cumulative() {
1367 let mk = |input: u64, cr: u64, cw: u64| UsageEntry {
1368 session_id: "s".into(),
1369 timestamp_ms: 0,
1370 model: "claude-opus-4-6".into(),
1371 input_tokens: input,
1372 output_tokens: 0,
1373 cache_read_tokens: cr,
1374 cache_write_tokens: cw,
1375 cache_write_1h_tokens: 0,
1376 web_search_requests: 0,
1377 speed: None,
1378 tool_uses: Vec::new(),
1379 text_chars: 0,
1380 activity: Activity::General,
1381 entrypoint: None,
1382 };
1383 let u = reduce_session(
1384 &[mk(15, 16_397, 25_661), mk(10, 42_058, 1_668)],
1385 "",
1386 "sid",
1387 )
1388 .unwrap();
1389 assert_eq!(u.total_context_sent, 15 + 16_397 + 25_661 + 10 + 42_058 + 1_668);
1390 }
1391
1392 #[test]
1393 fn reduce_cost_matches_opus_pricing() {
1394 let entries = vec![UsageEntry {
1396 session_id: "s".into(),
1397 timestamp_ms: 0,
1398 model: "claude-opus-4-6".into(),
1399 input_tokens: 1_000_000, output_tokens: 1_000_000, cache_read_tokens: 1_000_000, cache_write_tokens: 1_000_000, cache_write_1h_tokens: 0,
1404 web_search_requests: 0,
1405 speed: None,
1406 tool_uses: Vec::new(),
1407 text_chars: 0,
1408 activity: Activity::General,
1409 entrypoint: None,
1410 }];
1411 let u = reduce_session(&entries, "", "sid").unwrap();
1412 assert!((u.estimated_cost_usd - 36.75).abs() < 0.001);
1413 }
1414
1415 #[test]
1416 fn reduce_empty_returns_none() {
1417 assert!(reduce_session(&[], "", "sid").is_none());
1418 }
1419
1420 fn assistant_with_content(id: &str, ts: &str, content_json: &str) -> String {
1423 format!(
1424 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}}}}}"#
1425 )
1426 }
1427
1428 #[test]
1429 fn parse_extracts_tool_use_names() {
1430 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"}}]"#;
1431 let line = assistant_with_content("msg_1", "2026-04-14T08:27:38.895Z", content);
1432 let (_tmp, path) = write_jsonl(&[&line]);
1433 let entries = parse_jsonl_entries(&path).unwrap().entries;
1434 assert_eq!(entries.len(), 1);
1435 assert_eq!(entries[0].tool_uses, vec!["Edit".to_string(), "Read".to_string()]);
1436 }
1437
1438 #[test]
1439 fn parse_captures_text_char_count() {
1440 let content = r#"[{"type":"text","text":"Hello world"},{"type":"text","text":"!"}]"#;
1441 let line = assistant_with_content("msg_1", "2026-04-14T08:27:38.895Z", content);
1442 let (_tmp, path) = write_jsonl(&[&line]);
1443 let entries = parse_jsonl_entries(&path).unwrap().entries;
1444 assert_eq!(entries[0].text_chars, 12); }
1446
1447 #[test]
1448 fn parse_handles_missing_content_gracefully() {
1449 let line = assistant_line("msg_1", "claude-opus-4-6", "2026-04-14T08:27:38.895Z", 5, 10, 0, 0);
1452 let (_tmp, path) = write_jsonl(&[&line]);
1453 let entries = parse_jsonl_entries(&path).unwrap().entries;
1454 assert_eq!(entries.len(), 1);
1455 assert!(entries[0].tool_uses.is_empty());
1456 assert_eq!(entries[0].text_chars, 0);
1457 assert_eq!(entries[0].activity, Activity::General);
1458 }
1459
1460 #[test]
1461 fn parse_classifies_edit_plus_debug_as_debugging() {
1462 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();
1464 let content = r#"[{"type":"tool_use","id":"tu_1","name":"Edit","input":{"file_path":"login.rs"}}]"#;
1465 let asst_line = assistant_with_content("msg_1", "2026-04-14T08:27:38.895Z", content);
1466 let (_tmp, path) = write_jsonl(&[&user_line, &asst_line]);
1467 let entries = parse_jsonl_entries(&path).unwrap().entries;
1468 assert_eq!(entries.len(), 1);
1469 assert_eq!(entries[0].activity, Activity::Debugging);
1470 }
1471
1472 #[test]
1473 fn parse_classifies_bash_pytest_as_testing() {
1474 let user_line = r#"{"type":"user","timestamp":"2026-04-14T08:27:30.000Z","message":{"role":"user","content":"run the tests"}}"#.to_string();
1475 let content = r#"[{"type":"tool_use","id":"tu_1","name":"Bash","input":{"command":"pytest tests/unit"}}]"#;
1476 let asst_line = assistant_with_content("msg_1", "2026-04-14T08:27:38.895Z", content);
1477 let (_tmp, path) = write_jsonl(&[&user_line, &asst_line]);
1478 let entries = parse_jsonl_entries(&path).unwrap().entries;
1479 assert_eq!(entries[0].activity, Activity::Testing);
1480 }
1481
1482 #[test]
1483 fn parse_classifies_bash_git_as_gitops() {
1484 let content = r#"[{"type":"tool_use","id":"tu_1","name":"Bash","input":{"command":"git push origin main"}}]"#;
1485 let line = assistant_with_content("msg_1", "2026-04-14T08:27:38.895Z", content);
1486 let (_tmp, path) = write_jsonl(&[&line]);
1487 let entries = parse_jsonl_entries(&path).unwrap().entries;
1488 assert_eq!(entries[0].activity, Activity::GitOps);
1489 }
1490
1491 #[test]
1492 fn parse_tool_result_row_does_not_overwrite_user_text() {
1493 let user_real = r#"{"type":"user","timestamp":"2026-04-14T08:27:30.000Z","message":{"role":"user","content":"refactor the usage store"}}"#.to_string();
1498 let asst1 = assistant_with_content(
1499 "msg_1",
1500 "2026-04-14T08:27:35.000Z",
1501 r#"[{"type":"tool_use","id":"tu_1","name":"Read","input":{"file_path":"store.ts"}}]"#,
1502 );
1503 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();
1504 let asst2 = assistant_with_content(
1505 "msg_2",
1506 "2026-04-14T08:27:40.000Z",
1507 r#"[{"type":"tool_use","id":"tu_2","name":"Edit","input":{"file_path":"store.ts"}}]"#,
1508 );
1509 let (_tmp, path) = write_jsonl(&[&user_real, &asst1, &tool_result, &asst2]);
1510 let entries = parse_jsonl_entries(&path).unwrap().entries;
1511 assert_eq!(entries.len(), 2);
1512 assert_eq!(entries[0].activity, Activity::Exploration);
1514 assert_eq!(entries[1].activity, Activity::Refactor);
1517 }
1518
1519 #[test]
1520 fn parse_classifies_agent_turn_as_delegation() {
1521 let content = r#"[{"type":"tool_use","id":"tu_1","name":"Agent","input":{"description":"explore"}}]"#;
1522 let line = assistant_with_content("msg_1", "2026-04-14T08:27:38.895Z", content);
1523 let (_tmp, path) = write_jsonl(&[&line]);
1524 let entries = parse_jsonl_entries(&path).unwrap().entries;
1525 assert_eq!(entries[0].activity, Activity::Delegation);
1526 }
1527
1528 #[test]
1529 fn parse_pure_text_turn_is_conversation() {
1530 let user_line = r#"{"type":"user","timestamp":"2026-04-14T08:27:30.000Z","message":{"role":"user","content":"thanks"}}"#.to_string();
1531 let content = r#"[{"type":"text","text":"You're welcome — anything else?"}]"#;
1532 let asst_line = assistant_with_content("msg_1", "2026-04-14T08:27:38.895Z", content);
1533 let (_tmp, path) = write_jsonl(&[&user_line, &asst_line]);
1534 let entries = parse_jsonl_entries(&path).unwrap().entries;
1535 assert_eq!(entries[0].activity, Activity::Conversation);
1536 assert!(entries[0].text_chars > 0);
1537 }
1538
1539 #[test]
1540 fn parse_user_array_content_extracts_text_blocks() {
1541 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();
1544 let content = r#"[{"type":"tool_use","id":"tu_1","name":"Edit","input":{}}]"#;
1545 let asst_line = assistant_with_content("msg_1", "2026-04-14T08:27:38.895Z", content);
1546 let (_tmp, path) = write_jsonl(&[&user_line, &asst_line]);
1547 let entries = parse_jsonl_entries(&path).unwrap().entries;
1548 assert_eq!(entries[0].activity, Activity::Debugging);
1549 }
1550
1551 fn assistant_split_row(id: &str, ts: &str, content_json: &str) -> String {
1557 format!(
1558 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}}}}}"#
1559 )
1560 }
1561
1562 #[test]
1563 fn parse_merges_tool_uses_across_split_content_rows() {
1564 let thinking = assistant_split_row(
1570 "msg_01M6AvUVpAFjzSWcXeyGzUwV",
1571 "2026-05-13T09:09:03.500Z",
1572 r#"[{"type":"thinking","thinking":"plan"}]"#,
1573 );
1574 let text = assistant_split_row(
1575 "msg_01M6AvUVpAFjzSWcXeyGzUwV",
1576 "2026-05-13T09:09:03.550Z",
1577 r#"[{"type":"text","text":"Running tools in parallel."}]"#,
1578 );
1579 let tu1 = assistant_split_row(
1580 "msg_01M6AvUVpAFjzSWcXeyGzUwV",
1581 "2026-05-13T09:09:03.600Z",
1582 r#"[{"type":"tool_use","id":"tu_1","name":"mcp__example__list_configs","input":{"area":"rules"}}]"#,
1583 );
1584 let tu2 = assistant_split_row(
1585 "msg_01M6AvUVpAFjzSWcXeyGzUwV",
1586 "2026-05-13T09:09:03.700Z",
1587 r#"[{"type":"tool_use","id":"tu_2","name":"mcp__example__get_report","input":{}}]"#,
1588 );
1589 let tu3 = assistant_split_row(
1590 "msg_01M6AvUVpAFjzSWcXeyGzUwV",
1591 "2026-05-13T09:09:03.800Z",
1592 r#"[{"type":"tool_use","id":"tu_3","name":"Edit","input":{"file_path":"a.rs"}}]"#,
1593 );
1594 let (_tmp, path) = write_jsonl(&[&thinking, &text, &tu1, &tu2, &tu3]);
1595
1596 let entries = parse_jsonl_entries(&path).unwrap().entries;
1597 assert_eq!(entries.len(), 1, "still one entry per message.id");
1598
1599 let e = &entries[0];
1600 assert_eq!(
1601 e.tool_uses,
1602 vec![
1603 "mcp__example__list_configs".to_string(),
1604 "mcp__example__get_report".to_string(),
1605 "Edit".to_string(),
1606 ],
1607 "all tool_use names merged in row order"
1608 );
1609 assert!(e.text_chars > 0, "text from the text row is preserved");
1610
1611 assert_eq!(e.input_tokens, 6);
1615 assert_eq!(e.output_tokens, 746);
1616 assert_eq!(e.cache_read_tokens, 21859);
1617 assert_eq!(e.cache_write_tokens, 33027);
1618 }
1619
1620 #[test]
1621 fn parse_split_row_classifies_against_merged_content() {
1622 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();
1627 let thinking = assistant_split_row(
1628 "msg_split",
1629 "2026-05-13T09:09:01.000Z",
1630 r#"[{"type":"thinking","thinking":"investigating"}]"#,
1631 );
1632 let edit = assistant_split_row(
1633 "msg_split",
1634 "2026-05-13T09:09:02.000Z",
1635 r#"[{"type":"tool_use","id":"tu_1","name":"Edit","input":{"file_path":"auth.rs"}}]"#,
1636 );
1637 let (_tmp, path) = write_jsonl(&[&user, &thinking, &edit]);
1638
1639 let entries = parse_jsonl_entries(&path).unwrap().entries;
1640 assert_eq!(entries.len(), 1);
1641 assert_eq!(entries[0].activity, Activity::Debugging);
1642 assert_eq!(entries[0].tool_uses, vec!["Edit".to_string()]);
1643 }
1644
1645 #[test]
1646 fn parse_split_row_bash_commands_feed_classifier() {
1647 let user = r#"{"type":"user","timestamp":"2026-05-13T09:09:00.000Z","message":{"role":"user","content":"run tests"}}"#.to_string();
1650 let text = assistant_split_row(
1651 "msg_bash",
1652 "2026-05-13T09:09:01.000Z",
1653 r#"[{"type":"text","text":"Running the unit tests."}]"#,
1654 );
1655 let bash = assistant_split_row(
1656 "msg_bash",
1657 "2026-05-13T09:09:02.000Z",
1658 r#"[{"type":"tool_use","id":"tu_1","name":"Bash","input":{"command":"pytest tests/"}}]"#,
1659 );
1660 let (_tmp, path) = write_jsonl(&[&user, &text, &bash]);
1661
1662 let entries = parse_jsonl_entries(&path).unwrap().entries;
1663 assert_eq!(entries[0].activity, Activity::Testing);
1664 }
1665
1666 #[test]
1669 fn parse_extracts_hook_success_events() {
1670 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}}"#;
1671 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}}"#;
1672 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"}}"#;
1674 let asst = assistant_line("msg_01", "claude-opus-4-6", "2026-04-15T18:08:51.000Z", 5, 10, 0, 0);
1675 let (_tmp, path) = write_jsonl(&[hook_line, hook_fail, hook_msg, &asst]);
1676
1677 let parsed = parse_jsonl_entries(&path).unwrap();
1678 assert_eq!(parsed.entries.len(), 1, "assistant entries unaffected");
1679 assert_eq!(parsed.hooks.len(), 2, "two hook_success events");
1680
1681 assert_eq!(parsed.hooks[0].hook_name, "PostToolUse:Edit");
1682 assert_eq!(parsed.hooks[0].hook_event, "PostToolUse");
1683 assert_eq!(parsed.hooks[0].command, "verify-edit.sh");
1684 assert_eq!(parsed.hooks[0].exit_code, 0);
1685 assert_eq!(parsed.hooks[0].duration_ms, 90);
1686
1687 assert_eq!(parsed.hooks[0].tool_use_id.as_deref(), Some("tu_abc"));
1688
1689 assert_eq!(parsed.hooks[1].hook_name, "PreToolUse:Bash");
1690 assert_eq!(parsed.hooks[1].exit_code, 1);
1691 assert_eq!(parsed.hooks[1].duration_ms, 250);
1692 assert_eq!(parsed.hooks[1].tool_use_id.as_deref(), Some("tu_def"));
1693 }
1694
1695 #[test]
1696 fn parse_ignores_attachment_without_hook_data() {
1697 let non_hook = r#"{"type":"attachment","timestamp":"2026-04-14T08:27:26.236Z","attachment":{"type":"file_content","path":"foo.rs"}}"#;
1699 let asst = assistant_line("msg_01", "claude-opus-4-6", "2026-04-14T08:28:00.000Z", 1, 1, 0, 0);
1700 let (_tmp, path) = write_jsonl(&[non_hook, &asst]);
1701
1702 let parsed = parse_jsonl_entries(&path).unwrap();
1703 assert_eq!(parsed.entries.len(), 1);
1704 assert!(parsed.hooks.is_empty());
1705 }
1706
1707 #[test]
1708 fn snake_to_pascal_converts_correctly() {
1709 assert_eq!(snake_to_pascal("stop"), "Stop");
1710 assert_eq!(snake_to_pascal("pre_tool_use"), "PreToolUse");
1711 assert_eq!(snake_to_pascal("post_tool_use"), "PostToolUse");
1712 assert_eq!(snake_to_pascal("session_start"), "SessionStart");
1713 }
1714
1715 #[test]
1716 fn parse_extracts_system_hook_summary_v2() {
1717 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"}"#;
1719 let asst = assistant_line("msg_01", "claude-opus-4-6", "2026-04-25T09:06:13.000Z", 5, 10, 0, 0);
1720 let (_tmp, path) = write_jsonl(&[stop_summary, &asst]);
1721
1722 let parsed = parse_jsonl_entries(&path).unwrap();
1723 assert_eq!(parsed.entries.len(), 1);
1724 assert_eq!(parsed.hooks.len(), 1);
1725
1726 let h = &parsed.hooks[0];
1727 assert_eq!(h.hook_event, "Stop");
1728 assert_eq!(h.command, "Running build checks...");
1729 assert_eq!(h.duration_ms, 5227);
1730 assert_eq!(h.exit_code, 0, "no errors and not prevented = success");
1731 assert_eq!(h.tool_use_id.as_deref(), Some("tu_xyz"));
1732 }
1733
1734 #[test]
1735 fn parse_system_hook_summary_with_errors() {
1736 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"}"#;
1737 let asst = assistant_line("msg_02", "claude-opus-4-6", "2026-04-25T10:00:01.000Z", 1, 1, 0, 0);
1738 let (_tmp, path) = write_jsonl(&[summary, &asst]);
1739
1740 let parsed = parse_jsonl_entries(&path).unwrap();
1741 assert_eq!(parsed.hooks.len(), 1);
1742 assert_eq!(parsed.hooks[0].exit_code, 1, "errors or prevented = failure");
1743 }
1744
1745 #[test]
1746 fn parse_system_hook_summary_multiple_hooks() {
1747 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"}"#;
1748 let asst = assistant_line("msg_03", "claude-opus-4-6", "2026-04-25T11:00:01.000Z", 1, 1, 0, 0);
1749 let (_tmp, path) = write_jsonl(&[summary, &asst]);
1750
1751 let parsed = parse_jsonl_entries(&path).unwrap();
1752 assert_eq!(parsed.hooks.len(), 2);
1753 assert_eq!(parsed.hooks[0].command, "tsc --noEmit");
1754 assert_eq!(parsed.hooks[0].duration_ms, 1200);
1755 assert_eq!(parsed.hooks[1].command, "cargo check");
1756 assert_eq!(parsed.hooks[1].duration_ms, 3500);
1757 assert_eq!(parsed.hooks[0].tool_use_id, parsed.hooks[1].tool_use_id);
1758 }
1759
1760 #[test]
1761 fn hook_name_label_sanitizes_long_multiline_commands() {
1762 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.";
1766 let escaped = serde_json::to_string(long_cmd).unwrap();
1767 let summary = format!(
1768 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"}}"#
1769 );
1770 let asst = assistant_line("msg_goal", "claude-opus-4-6", "2026-04-25T12:00:01.000Z", 1, 1, 0, 0);
1771 let (_tmp, path) = write_jsonl(&[&summary, &asst]);
1772
1773 let parsed = parse_jsonl_entries(&path).unwrap();
1774 assert_eq!(parsed.hooks.len(), 1);
1775 let h = &parsed.hooks[0];
1776
1777 assert_eq!(h.command, long_cmd, "full command preserved on the entry");
1778 assert!(h.hook_name.starts_with("Stop:"), "name still scoped by event");
1779 assert!(h.hook_name.chars().count() <= "Stop:".len() + 65);
1781 assert!(h.hook_name.ends_with('…'), "long commands get ellipsis: {}", h.hook_name);
1782 assert!(!h.hook_name.contains('\n'), "no newlines in bucket key");
1783 }
1784
1785 #[test]
1786 fn hook_name_label_preserves_short_commands() {
1787 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"}"#;
1788 let asst = assistant_line("msg_short", "claude-opus-4-6", "2026-04-25T13:00:01.000Z", 1, 1, 0, 0);
1789 let (_tmp, path) = write_jsonl(&[summary, &asst]);
1790
1791 let parsed = parse_jsonl_entries(&path).unwrap();
1792 assert_eq!(parsed.hooks[0].hook_name, "Stop:cargo check");
1793 }
1794
1795 #[test]
1796 fn parse_handles_both_formats_in_same_file() {
1797 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}}"#;
1799 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"}"#;
1801 let asst = assistant_line("msg_both", "claude-opus-4-6", "2026-04-25T09:07:00.000Z", 1, 1, 0, 0);
1802 let (_tmp, path) = write_jsonl(&[old_hook, new_hook, &asst]);
1803
1804 let parsed = parse_jsonl_entries(&path).unwrap();
1805 assert_eq!(parsed.hooks.len(), 2, "both formats parsed");
1806 assert_eq!(parsed.hooks[0].hook_event, "PostToolUse");
1807 assert_eq!(parsed.hooks[1].hook_event, "Stop");
1808 }
1809
1810 #[allow(clippy::too_many_arguments)]
1813 fn entry_for_cost(
1814 model: &str,
1815 input: u64,
1816 output: u64,
1817 cr: u64,
1818 cw_total: u64,
1819 cw_1h: u64,
1820 web: u64,
1821 speed: Option<&str>,
1822 ) -> UsageEntry {
1823 UsageEntry {
1824 session_id: "s".into(),
1825 timestamp_ms: 0,
1826 model: model.into(),
1827 input_tokens: input,
1828 output_tokens: output,
1829 cache_read_tokens: cr,
1830 cache_write_tokens: cw_total,
1831 cache_write_1h_tokens: cw_1h,
1832 web_search_requests: web,
1833 speed: speed.map(|s| s.to_string()),
1834 tool_uses: Vec::new(),
1835 text_chars: 0,
1836 activity: Activity::General,
1837 entrypoint: None,
1838 }
1839 }
1840
1841 #[test]
1842 fn fable_pricing_all_token_classes() {
1843 let input = entry_for_cost("claude-fable-5", 1_000_000, 0, 0, 0, 0, 0, None);
1845 assert!((cost_for_entry(&input) - 10.0).abs() < 1e-6);
1846 let output = entry_for_cost("claude-fable-5", 0, 1_000_000, 0, 0, 0, 0, None);
1847 assert!((cost_for_entry(&output) - 50.0).abs() < 1e-6);
1848 let read = entry_for_cost("claude-fable-5", 0, 0, 1_000_000, 0, 0, 0, None);
1849 assert!((cost_for_entry(&read) - 1.0).abs() < 1e-6);
1850 let w5m = entry_for_cost("claude-fable-5", 0, 0, 0, 1_000_000, 0, 0, None);
1851 assert!((cost_for_entry(&w5m) - 12.5).abs() < 1e-6);
1852 let w1h = entry_for_cost("claude-fable-5", 0, 0, 0, 1_000_000, 1_000_000, 0, None);
1853 assert!((cost_for_entry(&w1h) - 20.0).abs() < 1e-6);
1854 }
1855
1856 #[test]
1857 fn fable_has_no_fast_premium() {
1858 let fast = entry_for_cost("claude-fable-5", 1_000_000, 0, 0, 0, 0, 0, Some("fast"));
1861 assert!((cost_for_entry(&fast) - 10.0).abs() < 1e-6);
1862 }
1863
1864 #[test]
1865 fn fable_context_window_is_natively_1m() {
1866 assert_eq!(context_window_for_model("claude-fable-5"), 1_000_000);
1868 assert_eq!(context_window_for_model("claude-opus-4-8"), 200_000);
1869 assert_eq!(context_window_for_model("claude-opus-4-8[1m]"), 1_000_000);
1870 }
1871
1872 #[test]
1873 fn cost_splits_5m_and_1h_cache_writes() {
1874 let all_1h = entry_for_cost("claude-opus-4-8", 0, 0, 0, 1_000_000, 1_000_000, 0, None);
1876 assert!((cost_for_entry(&all_1h) - 10.0).abs() < 1e-6);
1877 let all_5m = entry_for_cost("claude-opus-4-8", 0, 0, 0, 1_000_000, 0, 0, None);
1878 assert!((cost_for_entry(&all_5m) - 6.25).abs() < 1e-6);
1879 let mixed = entry_for_cost("claude-opus-4-8", 0, 0, 0, 1_000_000, 400_000, 0, None);
1881 assert!((cost_for_entry(&mixed) - 7.75).abs() < 1e-6);
1882 }
1883
1884 #[test]
1885 fn cost_adds_flat_web_search_fee() {
1886 let e = entry_for_cost("claude-sonnet-4-6", 0, 0, 0, 0, 0, 3, None);
1888 assert!((cost_for_entry(&e) - 0.03).abs() < 1e-9);
1889 }
1890
1891 #[test]
1892 fn fast_mode_multiplier_scales_token_cost_not_web_fee() {
1893 let std = entry_for_cost("claude-opus-4-8", 1_000_000, 0, 0, 0, 0, 0, Some("standard"));
1895 assert!((cost_for_entry(&std) - 5.0).abs() < 1e-6);
1896 let fast = entry_for_cost("claude-opus-4-8", 1_000_000, 0, 0, 0, 0, 0, Some("fast"));
1897 assert!((cost_for_entry(&fast) - 10.0).abs() < 1e-6);
1898 let fast46 = entry_for_cost("claude-opus-4-6", 1_000_000, 0, 0, 0, 0, 0, Some("fast"));
1900 assert!((cost_for_entry(&fast46) - 30.0).abs() < 1e-6);
1901 let fast_web = entry_for_cost("claude-opus-4-8", 1_000_000, 0, 0, 0, 0, 1, Some("fast"));
1903 assert!((cost_for_entry(&fast_web) - 10.01).abs() < 1e-6);
1904 }
1905
1906 #[test]
1907 fn fast_multiplier_only_for_known_opus() {
1908 assert_eq!(fast_multiplier_for_model("claude-opus-4-8"), 2.0);
1909 assert_eq!(fast_multiplier_for_model("claude-opus-4-7"), 6.0);
1910 assert_eq!(fast_multiplier_for_model("claude-opus-4-6"), 6.0);
1911 assert_eq!(fast_multiplier_for_model("claude-opus-5"), 2.0);
1912 assert_eq!(fast_multiplier_for_model("claude-opus-5[1m]"), 2.0);
1913 assert_eq!(fast_multiplier_for_model("claude-opus-5-5"), 2.0);
1914 assert_eq!(fast_multiplier_for_model("claude-opus-5-5[1m]"), 2.0);
1915 assert_eq!(fast_multiplier_for_model("claude-sonnet-4-6"), 1.0);
1916 assert_eq!(fast_multiplier_for_model("claude-fable-5-1"), 1.0);
1917 assert_eq!(fast_multiplier_for_model("claude-opus-6"), 1.0);
1919 }
1920
1921 #[test]
1922 fn fable_5_1_reads_cache_at_a_quarter_of_fable_5() {
1923 let read51 = entry_for_cost("claude-fable-5-1", 0, 0, 1_000_000, 0, 0, 0, None);
1926 assert!((cost_for_entry(&read51) - 0.25).abs() < 1e-6);
1927 let read5 = entry_for_cost("claude-fable-5", 0, 0, 1_000_000, 0, 0, 0, None);
1928 assert!((cost_for_entry(&read5) - 1.0).abs() < 1e-6);
1929
1930 let input = entry_for_cost("claude-fable-5-1", 1_000_000, 0, 0, 0, 0, 0, None);
1931 assert!((cost_for_entry(&input) - 10.0).abs() < 1e-6);
1932 let output = entry_for_cost("claude-fable-5-1", 0, 1_000_000, 0, 0, 0, 0, None);
1933 assert!((cost_for_entry(&output) - 50.0).abs() < 1e-6);
1934 let w5m = entry_for_cost("claude-fable-5-1", 0, 0, 0, 1_000_000, 0, 0, None);
1935 assert!((cost_for_entry(&w5m) - 12.5).abs() < 1e-6);
1936 let w1h = entry_for_cost("claude-fable-5-1", 0, 0, 0, 1_000_000, 1_000_000, 0, None);
1937 assert!((cost_for_entry(&w1h) - 20.0).abs() < 1e-6);
1938 }
1939
1940 #[test]
1941 fn opus_5_5_is_cheaper_than_opus_5() {
1942 let in55 = entry_for_cost("claude-opus-5-5", 1_000_000, 0, 0, 0, 0, 0, None);
1945 assert!((cost_for_entry(&in55) - 4.0).abs() < 1e-6);
1946 let out55 = entry_for_cost("claude-opus-5-5", 0, 1_000_000, 0, 0, 0, 0, None);
1947 assert!((cost_for_entry(&out55) - 20.0).abs() < 1e-6);
1948 let read55 = entry_for_cost("claude-opus-5-5", 0, 0, 1_000_000, 0, 0, 0, None);
1949 assert!((cost_for_entry(&read55) - 0.2).abs() < 1e-6);
1950 let w5m = entry_for_cost("claude-opus-5-5", 0, 0, 0, 1_000_000, 0, 0, None);
1951 assert!((cost_for_entry(&w5m) - 5.0).abs() < 1e-6);
1952 let w1h = entry_for_cost("claude-opus-5-5", 0, 0, 0, 1_000_000, 1_000_000, 0, None);
1953 assert!((cost_for_entry(&w1h) - 8.0).abs() < 1e-6);
1954
1955 let in55_1m = entry_for_cost("claude-opus-5-5[1m]", 1_000_000, 0, 0, 0, 0, 0, None);
1957 assert!((cost_for_entry(&in55_1m) - 4.0).abs() < 1e-6);
1958 let fast = entry_for_cost("claude-opus-5-5", 1_000_000, 0, 0, 0, 0, 0, Some("fast"));
1959 assert!((cost_for_entry(&fast) - 8.0).abs() < 1e-6);
1960
1961 let in5 = entry_for_cost("claude-opus-5", 1_000_000, 0, 0, 0, 0, 0, None);
1963 assert!((cost_for_entry(&in5) - 5.0).abs() < 1e-6);
1964 assert_eq!(pricing_for_model("opus").input, 5.0);
1965 assert_eq!(pricing_for_model("opus").cache_read, 0.5);
1966 }
1967
1968 #[test]
1969 fn sonnet_5_is_cheaper_than_sonnet_4_6() {
1970 let in5 = entry_for_cost("claude-sonnet-5", 1_000_000, 0, 0, 0, 0, 0, None);
1973 assert!((cost_for_entry(&in5) - 2.0).abs() < 1e-6);
1974 let out5 = entry_for_cost("claude-sonnet-5", 0, 1_000_000, 0, 0, 0, 0, None);
1975 assert!((cost_for_entry(&out5) - 10.0).abs() < 1e-6);
1976 let read5 = entry_for_cost("claude-sonnet-5", 0, 0, 1_000_000, 0, 0, 0, None);
1977 assert!((cost_for_entry(&read5) - 0.2).abs() < 1e-6);
1978 let w5m = entry_for_cost("claude-sonnet-5", 0, 0, 0, 1_000_000, 0, 0, None);
1979 assert!((cost_for_entry(&w5m) - 2.5).abs() < 1e-6);
1980 let w1h = entry_for_cost("claude-sonnet-5", 0, 0, 0, 1_000_000, 1_000_000, 0, None);
1981 assert!((cost_for_entry(&w1h) - 4.0).abs() < 1e-6);
1982
1983 let in46 = entry_for_cost("claude-sonnet-4-6", 1_000_000, 0, 0, 0, 0, 0, None);
1984 assert!((cost_for_entry(&in46) - 3.0).abs() < 1e-6);
1985
1986 let in5_1m = entry_for_cost("claude-sonnet-5[1m]", 1_000_000, 0, 0, 0, 0, 0, None);
1988 assert!((cost_for_entry(&in5_1m) - 2.0).abs() < 1e-6);
1989 }
1990
1991 #[test]
1992 fn unknown_models_keep_the_conservative_sonnet_rate() {
1993 for id in ["", "<unknown>", "sonnet", "claude-3-5-sonnet-20241022"] {
1996 let p = pricing_for_model(id);
1997 assert_eq!(p.input, 3.0, "{id} should price at the Sonnet default");
1998 }
1999 assert_eq!(pricing_for_model("fable").cache_read, 1.0);
2002 }
2003
2004 #[test]
2005 fn family_version_reads_minor_and_ignores_date_snapshots() {
2006 assert_eq!(family_version("claude-fable-5-1", "fable"), Some((5, 1)));
2007 assert_eq!(family_version("claude-fable-5", "fable"), Some((5, 0)));
2008 assert_eq!(family_version("claude-sonnet-5", "sonnet"), Some((5, 0)));
2009 assert_eq!(family_version("claude-sonnet-4-6", "sonnet"), Some((4, 6)));
2010 assert_eq!(family_version("claude-opus-5-5", "opus"), Some((5, 5)));
2011 assert_eq!(family_version("claude-opus-5-5[1m]", "opus"), Some((5, 5)));
2012 assert_eq!(
2013 family_version("claude-haiku-4-5-20251001", "haiku"),
2014 Some((4, 5))
2015 );
2016 assert_eq!(
2018 family_version("claude-sonnet-5-20260701", "sonnet"),
2019 Some((5, 0))
2020 );
2021 assert_eq!(family_version("fable", "fable"), None);
2022 assert_eq!(family_version("claude-opus-4-8", "sonnet"), None);
2023 assert_eq!(family_version("claude-3-5-sonnet-20241022", "sonnet"), None);
2026 }
2027
2028 #[test]
2029 fn parse_extracts_cache_split_speed_and_web_search() {
2030 let line = r#"{"type":"assistant","timestamp":"2026-05-29T10:00:00.000Z","message":{"id":"msg_1","model":"claude-opus-4-8","usage":{"input_tokens":2,"output_tokens":673,"cache_read_input_tokens":0,"cache_creation_input_tokens":40702,"cache_creation":{"ephemeral_5m_input_tokens":700,"ephemeral_1h_input_tokens":40002},"server_tool_use":{"web_search_requests":2,"web_fetch_requests":5},"speed":"fast"}}}"#;
2031 let (_tmp, path) = write_jsonl(&[line]);
2032 let entries = parse_jsonl_entries(&path).unwrap().entries;
2033 assert_eq!(entries.len(), 1);
2034 let e = &entries[0];
2035 assert_eq!(e.cache_write_tokens, 40702, "flat total preserved (== 5m+1h)");
2036 assert_eq!(e.cache_write_1h_tokens, 40002);
2037 assert_eq!(e.web_search_requests, 2);
2038 assert_eq!(e.speed.as_deref(), Some("fast"));
2039 }
2040
2041 #[test]
2042 fn parse_cache_write_takes_max_of_flat_and_split() {
2043 let line = r#"{"type":"assistant","timestamp":"2026-05-29T10:00:00.000Z","message":{"id":"msg_2","model":"claude-opus-4-8","usage":{"input_tokens":1,"output_tokens":1,"cache_read_input_tokens":0,"cache_creation_input_tokens":100,"cache_creation":{"ephemeral_5m_input_tokens":500,"ephemeral_1h_input_tokens":900}}}}"#;
2045 let (_tmp, path) = write_jsonl(&[line]);
2046 let e = &parse_jsonl_entries(&path).unwrap().entries[0];
2047 assert_eq!(e.cache_write_tokens, 1400, "max(100, 500+900)");
2048 assert_eq!(e.cache_write_1h_tokens, 900);
2049 }
2050
2051 #[test]
2052 fn parse_missing_nested_cache_falls_back_to_flat() {
2053 let line = assistant_line("msg_3", "claude-opus-4-8", "2026-05-29T10:00:00.000Z", 5, 10, 0, 30000);
2055 let (_tmp, path) = write_jsonl(&[&line]);
2056 let e = &parse_jsonl_entries(&path).unwrap().entries[0];
2057 assert_eq!(e.cache_write_tokens, 30000);
2058 assert_eq!(e.cache_write_1h_tokens, 0);
2059 assert_eq!(e.web_search_requests, 0);
2060 assert_eq!(e.speed, None);
2061 }
2062
2063 #[test]
2064 fn reduce_session_prices_1h_cache_premium() {
2065 let e = entry_for_cost("claude-opus-4-8", 0, 0, 0, 1_000_000, 1_000_000, 0, None);
2067 let u = reduce_session(&[e], "", "sid").unwrap();
2068 assert!((u.estimated_cost_usd - 10.0).abs() < 1e-6);
2069 }
2070}