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
65const HAIKU_LONG_PROMPT_TOKENS: u64 = 100_000;
69
70fn is_haiku_5_5(model: &str) -> bool {
71 matches!(family_version(model, "haiku"), Some(v) if v >= (5, 5))
72}
73
74fn haiku_5_5_pricing(long_prompt: bool) -> ModelPricing {
75 if long_prompt {
76 ModelPricing {
77 input: 0.5,
78 output: 2.5,
79 cache_read: 0.05,
80 cache_write: 0.625,
81 cache_write_1h: 1.0,
82 }
83 } else {
84 ModelPricing {
85 input: 0.1,
86 output: 0.5,
87 cache_read: 0.01,
88 cache_write: 0.125,
89 cache_write_1h: 0.2,
90 }
91 }
92}
93
94pub fn pricing_for_model(model: &str) -> ModelPricing {
110 if model.contains("fable") {
111 let cache_read = match family_version(model, "fable") {
114 Some(v) if v >= (5, 1) => 0.25,
115 _ => 1.0,
116 };
117 ModelPricing {
118 input: 10.0,
119 output: 50.0,
120 cache_read,
121 cache_write: 12.5,
122 cache_write_1h: 20.0,
123 }
124 } else if matches!(family_version(model, "opus"), Some(v) if v >= (5, 5)) {
125 ModelPricing {
128 input: 4.0,
129 output: 20.0,
130 cache_read: 0.2,
131 cache_write: 5.0,
132 cache_write_1h: 8.0,
133 }
134 } else if model.contains("opus") {
135 ModelPricing {
136 input: 5.0,
137 output: 25.0,
138 cache_read: 0.5,
139 cache_write: 6.25,
140 cache_write_1h: 10.0,
141 }
142 } else if is_haiku_5_5(model) {
143 haiku_5_5_pricing(false)
144 } else if model.contains("haiku") {
145 ModelPricing {
146 input: 1.0,
147 output: 5.0,
148 cache_read: 0.1,
149 cache_write: 1.25,
150 cache_write_1h: 2.0,
151 }
152 } else if let Some(v) = family_version(model, "sonnet").filter(|v| *v >= (5, 0)) {
153 ModelPricing {
155 input: 2.0,
156 output: 10.0,
157 cache_read: if v >= (5, 5) { 0.1 } else { 0.2 },
158 cache_write: 2.5,
159 cache_write_1h: 4.0,
160 }
161 } else {
162 ModelPricing {
164 input: 3.0,
165 output: 15.0,
166 cache_read: 0.3,
167 cache_write: 3.75,
168 cache_write_1h: 6.0,
169 }
170 }
171}
172
173pub fn pricing_for_entry(entry: &UsageEntry) -> ModelPricing {
177 let prompt = entry.input_tokens + entry.cache_read_tokens + entry.cache_write_tokens;
178 if is_haiku_5_5(&entry.model) && prompt > HAIKU_LONG_PROMPT_TOKENS {
179 haiku_5_5_pricing(true)
180 } else {
181 pricing_for_model(&entry.model)
182 }
183}
184
185pub const WEB_SEARCH_COST_PER_REQUEST: f64 = 0.01;
187
188pub fn fast_multiplier_for_model(model: &str) -> f64 {
201 if model.contains("opus-4-8") || model.contains("opus-5") {
202 2.0
203 } else if model.contains("opus-4-7") || model.contains("opus-4-6") {
204 6.0
205 } else {
206 1.0
207 }
208}
209
210pub fn cost_for_entry(entry: &UsageEntry) -> f64 {
218 let p = pricing_for_entry(entry);
219 let cw_1h = entry.cache_write_1h_tokens.min(entry.cache_write_tokens);
220 let cw_5m = entry.cache_write_tokens - cw_1h;
221 let token_cost = entry.input_tokens as f64 * p.input
222 + entry.output_tokens as f64 * p.output
223 + entry.cache_read_tokens as f64 * p.cache_read
224 + cw_5m as f64 * p.cache_write
225 + cw_1h as f64 * p.cache_write_1h;
226 let fast = if entry.speed.as_deref() == Some("fast") {
227 fast_multiplier_for_model(&entry.model)
228 } else {
229 1.0
230 };
231 (token_cost * fast) / 1_000_000.0
232 + entry.web_search_requests as f64 * WEB_SEARCH_COST_PER_REQUEST
233}
234
235pub fn context_window_for_model(model: &str) -> u64 {
240 let native_1m = model.contains("fable")
241 || is_haiku_5_5(model)
242 || matches!(family_version(model, "opus"), Some(v) if v >= (5, 0))
243 || matches!(family_version(model, "sonnet"), Some(v) if v >= (5, 0));
244 if model.contains("[1m]") || native_1m {
245 1_000_000
246 } else {
247 200_000
248 }
249}
250
251#[derive(Debug, Clone, Serialize, Deserialize)]
267#[serde(rename_all = "camelCase")]
268pub struct UsageEntry {
269 pub session_id: String,
270 pub timestamp_ms: u64,
272 pub model: String,
273 pub input_tokens: u64,
274 pub output_tokens: u64,
275 pub cache_read_tokens: u64,
276 pub cache_write_tokens: u64,
281 #[serde(default)]
285 pub cache_write_1h_tokens: u64,
286 #[serde(default)]
289 pub web_search_requests: u64,
290 #[serde(default)]
294 pub speed: Option<String>,
295 pub tool_uses: Vec<String>,
299 pub text_chars: u32,
302 pub activity: Activity,
305 pub entrypoint: Option<String>,
312}
313
314#[derive(Debug, Clone, Serialize, Deserialize)]
317#[serde(rename_all = "camelCase")]
318pub struct HookExecution {
319 pub session_id: String,
320 pub timestamp_ms: u64,
321 pub hook_event: String,
323 pub hook_name: String,
325 pub command: String,
327 pub exit_code: i32,
328 pub duration_ms: u64,
329 pub tool_use_id: Option<String>,
331}
332
333#[derive(Default)]
335pub struct ParsedJsonl {
336 pub entries: Vec<UsageEntry>,
337 pub hooks: Vec<HookExecution>,
338}
339
340#[derive(Debug, Clone, Serialize, Default)]
342#[serde(rename_all = "camelCase")]
343pub struct SessionUsage {
344 pub model: String,
345 pub input_tokens: u64,
346 pub output_tokens: u64,
347 pub cache_read_tokens: u64,
348 pub cache_write_tokens: u64,
349 pub total_tokens: u64,
350 pub estimated_cost_usd: f64,
351 pub context_window_size: u64,
352 pub context_window_used_pct: f64,
353 pub current_context_tokens: u64,
355 pub total_context_sent: u64,
357 pub cache_hit_pct: f64,
359 pub message_count: u64,
360 pub session_id: String,
361}
362
363#[derive(Deserialize)]
365#[serde(rename_all = "camelCase")]
366struct JsonlRaw {
367 #[serde(rename = "type")]
368 entry_type: Option<String>,
369 timestamp: Option<String>,
370 message: Option<MessageData>,
371 attachment: Option<AttachmentData>,
372 subtype: Option<String>,
374 #[allow(dead_code)]
375 hook_count: Option<usize>,
376 hook_infos: Option<Vec<HookInfoEntry>>,
377 hook_errors: Option<Vec<serde_json::Value>>,
378 prevented_continuation: Option<bool>,
379 #[serde(rename = "toolUseID")]
380 tool_use_id: Option<String>,
381 entrypoint: Option<String>,
384}
385
386#[derive(Deserialize)]
387struct MessageData {
388 id: Option<String>,
389 model: Option<String>,
390 usage: Option<UsageData>,
391 #[serde(default)]
396 content: Option<serde_json::Value>,
397}
398
399#[derive(Deserialize, Default)]
400struct UsageData {
401 input_tokens: Option<u64>,
402 output_tokens: Option<u64>,
403 cache_read_input_tokens: Option<u64>,
404 cache_creation_input_tokens: Option<u64>,
407 cache_creation: Option<CacheCreation>,
409 server_tool_use: Option<ServerToolUse>,
411 speed: Option<String>,
413}
414
415#[derive(Deserialize, Default)]
417struct CacheCreation {
418 ephemeral_5m_input_tokens: Option<u64>,
419 ephemeral_1h_input_tokens: Option<u64>,
420}
421
422#[derive(Deserialize, Default)]
425struct ServerToolUse {
426 web_search_requests: Option<u64>,
427}
428
429#[derive(Deserialize)]
431#[serde(rename_all = "camelCase")]
432struct HookInfoEntry {
433 command: Option<String>,
434 duration_ms: Option<u64>,
435}
436
437#[derive(Deserialize)]
439#[serde(rename_all = "camelCase")]
440struct AttachmentData {
441 #[serde(rename = "type")]
442 attachment_type: Option<String>,
443 hook_name: Option<String>,
444 hook_event: Option<String>,
445 command: Option<String>,
446 exit_code: Option<i32>,
447 duration_ms: Option<u64>,
448 #[serde(rename = "toolUseID")]
449 tool_use_id: Option<String>,
450}
451
452#[derive(Deserialize)]
454#[serde(rename_all = "camelCase")]
455struct ActiveSession {
456 session_id: String,
457 cwd: String,
458 started_at: u64,
459}
460
461use super::MAX_JSONL_BYTES;
468
469const MAX_ENCODED_LEN: usize = 200;
471
472pub fn encode_project_path(project_path: &str) -> String {
483 let trimmed = project_path.trim_end_matches(['/', '\\']);
484 let path = if trimmed.is_empty() || trimmed.ends_with(':') {
485 project_path
486 } else {
487 trimmed
488 };
489 let encoded: String = path
490 .encode_utf16()
491 .map(|unit| match u8::try_from(unit) {
492 Ok(b) if b.is_ascii_alphanumeric() => b as char,
493 _ => '-',
494 })
495 .collect();
496 if encoded.len() <= MAX_ENCODED_LEN {
497 return encoded;
498 }
499 format!(
500 "{}-{}",
501 &encoded[..MAX_ENCODED_LEN],
502 base36(path_hash(path).unsigned_abs())
503 )
504}
505
506fn path_hash(path: &str) -> i32 {
508 path.encode_utf16()
509 .fold(0i32, |h, unit| (h << 5).wrapping_sub(h).wrapping_add(i32::from(unit)))
510}
511
512fn base36(mut n: u32) -> String {
513 const DIGITS: &[u8; 36] = b"0123456789abcdefghijklmnopqrstuvwxyz";
514 let mut out = Vec::new();
515 loop {
516 out.push(DIGITS[(n % 36) as usize]);
517 n /= 36;
518 if n == 0 {
519 break;
520 }
521 }
522 out.reverse();
523 String::from_utf8(out).expect("ASCII digits")
524}
525
526struct AssistantContent {
528 tool_uses: Vec<String>,
529 bash_cmds: Vec<String>,
530 text_chars: u32,
531}
532
533fn extract_assistant_content(content: Option<&serde_json::Value>) -> AssistantContent {
537 let mut out = AssistantContent {
538 tool_uses: Vec::new(),
539 bash_cmds: Vec::new(),
540 text_chars: 0,
541 };
542 let arr = match content.and_then(|v| v.as_array()) {
543 Some(a) => a,
544 None => return out,
545 };
546
547 for block in arr {
548 let block_type = match block.get("type").and_then(|v| v.as_str()) {
549 Some(t) => t,
550 None => continue,
551 };
552 match block_type {
553 "tool_use" => {
554 if let Some(name) = block.get("name").and_then(|v| v.as_str()) {
555 if matches!(name, "Bash" | "BashTool" | "PowerShellTool") {
558 if let Some(cmd) = block
559 .get("input")
560 .and_then(|i| i.get("command"))
561 .and_then(|c| c.as_str())
562 {
563 out.bash_cmds.push(cmd.to_string());
564 }
565 }
566 out.tool_uses.push(name.to_string());
567 }
568 }
569 "text" => {
570 if let Some(text) = block.get("text").and_then(|v| v.as_str()) {
571 out.text_chars = out
574 .text_chars
575 .saturating_add(text.chars().count() as u32);
576 }
577 }
578 _ => {}
579 }
580 }
581
582 out
583}
584
585fn sanitize_hook_command_label(cmd: &str) -> String {
604 const MAX_LEN: usize = 64;
605 let first_line = cmd.lines().next().unwrap_or("").trim();
606 let collapsed: String = first_line.split_whitespace().collect::<Vec<_>>().join(" ");
607 if collapsed.chars().count() <= MAX_LEN {
608 return collapsed;
609 }
610 let truncated: String = collapsed.chars().take(MAX_LEN).collect();
611 format!("{truncated}…")
612}
613
614fn snake_to_pascal(s: &str) -> String {
616 s.split('_')
617 .filter(|w| !w.is_empty())
618 .map(|w| {
619 let mut c = w.chars();
620 match c.next() {
621 Some(first) => {
622 let mut out = first.to_uppercase().to_string();
623 out.extend(c);
624 out
625 }
626 None => String::new(),
627 }
628 })
629 .collect()
630}
631
632fn extract_user_text(content: &serde_json::Value) -> Option<String> {
635 match content {
636 serde_json::Value::String(s) => {
637 if s.is_empty() {
638 None
639 } else {
640 Some(s.clone())
641 }
642 }
643 serde_json::Value::Array(arr) => {
644 let mut parts: Vec<&str> = Vec::new();
645 for item in arr {
646 let t = item.get("type").and_then(|v| v.as_str()).unwrap_or("");
647 if t == "text" {
648 if let Some(text) = item.get("text").and_then(|v| v.as_str()) {
649 if !text.is_empty() {
650 parts.push(text);
651 }
652 }
653 }
654 }
655 if parts.is_empty() {
656 None
657 } else {
658 Some(parts.join(" "))
659 }
660 }
661 _ => None,
662 }
663}
664
665struct EntryAccumulator {
668 out_idx: usize,
669 bash_cmds: Vec<String>,
670 user_text_at_first: Option<String>,
671}
672
673pub fn parse_jsonl_entries(path: &Path) -> std::io::Result<ParsedJsonl> {
697 let meta = fs::metadata(path)?;
698 if meta.len() > MAX_JSONL_BYTES {
699 tracing::warn!(
700 path = %path.display(),
701 size = meta.len(),
702 "skipping JSONL larger than {}MB",
703 MAX_JSONL_BYTES / 1024 / 1024
704 );
705 return Ok(ParsedJsonl::default());
706 }
707
708 let file = fs::File::open(path)?;
709 let reader = BufReader::new(file);
710
711 let session_id = path
712 .file_stem()
713 .and_then(|s| s.to_str())
714 .unwrap_or("")
715 .to_string();
716
717 let mut accums: HashMap<String, EntryAccumulator> = HashMap::new();
718 let mut out: Vec<UsageEntry> = Vec::new();
719 let mut hooks: Vec<HookExecution> = Vec::new();
720 let mut last_user_text: Option<String> = None;
721
722 for line in reader.lines() {
723 let line = match line {
724 Ok(l) => l,
725 Err(_) => continue,
726 };
727 let line = line.trim();
728 if line.is_empty() {
729 continue;
730 }
731
732 let raw: JsonlRaw = match serde_json::from_str(line) {
733 Ok(r) => r,
734 Err(_) => continue,
735 };
736
737 let entry_type = raw.entry_type.as_deref();
738
739 if entry_type == Some("user") {
742 if let Some(msg) = raw.message.as_ref() {
743 if let Some(content) = msg.content.as_ref() {
744 if let Some(text) = extract_user_text(content) {
745 last_user_text = Some(normalize_user_text(&text));
746 }
747 }
748 }
749 continue;
750 }
751
752 if entry_type == Some("attachment") {
754 if let Some(att) = raw.attachment {
755 if att.attachment_type.as_deref() == Some("hook_success") {
756 let timestamp_ms = raw
757 .timestamp
758 .as_deref()
759 .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
760 .map(|dt| dt.timestamp_millis().max(0) as u64)
761 .unwrap_or(0);
762 hooks.push(HookExecution {
763 session_id: session_id.clone(),
764 timestamp_ms,
765 hook_event: att.hook_event.unwrap_or_default(),
766 hook_name: att.hook_name.unwrap_or_default(),
767 command: att.command.unwrap_or_default(),
768 exit_code: att.exit_code.unwrap_or(0),
769 duration_ms: att.duration_ms.unwrap_or(0),
770 tool_use_id: att.tool_use_id,
771 });
772 }
773 }
774 continue;
775 }
776
777 if entry_type == Some("system") {
780 if let Some(subtype) = raw.subtype.as_deref() {
781 if let Some(event_snake) = subtype.strip_suffix("_hook_summary") {
782 let hook_event = snake_to_pascal(event_snake);
783 let timestamp_ms = raw
784 .timestamp
785 .as_deref()
786 .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
787 .map(|dt| dt.timestamp_millis().max(0) as u64)
788 .unwrap_or(0);
789 let had_errors = raw
790 .hook_errors
791 .as_ref()
792 .is_some_and(|e| !e.is_empty());
793 let prevented = raw.prevented_continuation.unwrap_or(false);
794 if let Some(infos) = raw.hook_infos {
795 for info in infos {
796 let cmd = info.command.unwrap_or_default();
797 let hook_name =
798 format!("{}:{}", hook_event, sanitize_hook_command_label(&cmd));
799 hooks.push(HookExecution {
800 session_id: session_id.clone(),
801 timestamp_ms,
802 hook_event: hook_event.clone(),
803 hook_name,
804 command: cmd,
805 exit_code: if had_errors || prevented { 1 } else { 0 },
806 duration_ms: info.duration_ms.unwrap_or(0),
807 tool_use_id: raw.tool_use_id.clone(),
808 });
809 }
810 }
811 }
812 }
813 continue;
814 }
815
816 if entry_type != Some("assistant") {
817 continue;
818 }
819
820 let msg = match raw.message {
821 Some(m) => m,
822 None => continue,
823 };
824 let mid = match msg.id {
825 Some(id) => id,
826 None => continue,
827 };
828
829 let mut content_parsed = extract_assistant_content(msg.content.as_ref());
830
831 if let Some(acc) = accums.get_mut(&mid) {
834 let entry = &mut out[acc.out_idx];
835 entry
836 .tool_uses
837 .append(&mut content_parsed.tool_uses);
838 entry.text_chars = entry
839 .text_chars
840 .saturating_add(content_parsed.text_chars);
841 acc.bash_cmds.append(&mut content_parsed.bash_cmds);
842 continue;
843 }
844
845 let model = msg.model.unwrap_or_default();
851 if model == "<synthetic>" {
852 continue;
853 }
854
855 let usage = msg.usage.unwrap_or_default();
856 let timestamp_ms = raw
857 .timestamp
858 .as_deref()
859 .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
860 .map(|dt| dt.timestamp_millis().max(0) as u64)
861 .unwrap_or(0);
862
863 let cw_flat = usage.cache_creation_input_tokens.unwrap_or(0);
867 let (eph_5m, eph_1h) = usage
868 .cache_creation
869 .as_ref()
870 .map(|c| {
871 (
872 c.ephemeral_5m_input_tokens.unwrap_or(0),
873 c.ephemeral_1h_input_tokens.unwrap_or(0),
874 )
875 })
876 .unwrap_or((0, 0));
877 let cache_write_tokens = cw_flat.max(eph_5m + eph_1h);
878 let cache_write_1h_tokens = eph_1h.min(cache_write_tokens);
879 let web_search_requests = usage
880 .server_tool_use
881 .as_ref()
882 .map(|s| s.web_search_requests.unwrap_or(0))
883 .unwrap_or(0);
884 let speed = usage.speed.clone();
885
886 accums.insert(
890 mid.clone(),
891 EntryAccumulator {
892 out_idx: out.len(),
893 bash_cmds: std::mem::take(&mut content_parsed.bash_cmds),
894 user_text_at_first: last_user_text.clone(),
895 },
896 );
897 out.push(UsageEntry {
898 session_id: session_id.clone(),
899 timestamp_ms,
900 model,
901 input_tokens: usage.input_tokens.unwrap_or(0),
902 output_tokens: usage.output_tokens.unwrap_or(0),
903 cache_read_tokens: usage.cache_read_input_tokens.unwrap_or(0),
904 cache_write_tokens,
905 cache_write_1h_tokens,
906 web_search_requests,
907 speed,
908 tool_uses: content_parsed.tool_uses,
909 text_chars: content_parsed.text_chars,
910 activity: Activity::General,
911 entrypoint: raw.entrypoint.clone(),
912 });
913 }
914
915 for acc in accums.values() {
918 let entry = &mut out[acc.out_idx];
919 let tool_refs: Vec<&str> = entry.tool_uses.iter().map(|s| s.as_str()).collect();
920 let bash_refs: Vec<&str> = acc.bash_cmds.iter().map(|s| s.as_str()).collect();
921 entry.activity = classify(
922 &tool_refs,
923 &bash_refs,
924 acc.user_text_at_first.as_deref(),
925 entry.text_chars,
926 );
927 }
928
929 Ok(ParsedJsonl {
930 entries: out,
931 hooks,
932 })
933}
934
935pub fn find_current_session_jsonl(
939 config_dir: &str,
940 project_path: &str,
941 spawned_at: SystemTime,
942) -> Option<PathBuf> {
943 let config = Path::new(config_dir);
944 let encoded = encode_project_path(project_path);
945 let projects_dir = config.join("projects").join(&encoded);
946
947 if !projects_dir.exists() {
948 return None;
949 }
950
951 let sessions_dir = config.join("sessions");
954 if sessions_dir.exists() {
955 if let Some(session_id) = find_active_session_id(&sessions_dir, project_path, spawned_at) {
956 let jsonl_path = projects_dir.join(format!("{}.jsonl", session_id));
957 if jsonl_path.exists() {
958 return Some(jsonl_path);
959 }
960 }
961 }
962
963 let mut candidates: Vec<(PathBuf, SystemTime)> = Vec::new();
968
969 if let Ok(entries) = fs::read_dir(&projects_dir) {
970 for entry in entries.flatten() {
971 let path = entry.path();
972 if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
973 continue;
974 }
975 if let Ok(meta) = path.metadata() {
976 let created = meta.created().unwrap_or(SystemTime::UNIX_EPOCH);
977 if created >= spawned_at {
978 candidates.push((path, created));
979 }
980 }
981 }
982 }
983
984 candidates.sort_by_key(|a| a.1);
986 candidates.into_iter().next().map(|(p, _)| p)
987}
988
989fn find_active_session_id(
992 sessions_dir: &Path,
993 project_path: &str,
994 spawned_at: SystemTime,
995) -> Option<String> {
996 let spawned_ms = spawned_at
997 .duration_since(SystemTime::UNIX_EPOCH)
998 .ok()?
999 .as_millis() as u64;
1000
1001 let threshold_ms = spawned_ms.saturating_sub(10_000);
1003
1004 let normalized_project = normalize_path(project_path);
1005
1006 let mut best: Option<(String, u64)> = None;
1007
1008 if let Ok(entries) = fs::read_dir(sessions_dir) {
1009 for entry in entries.flatten() {
1010 let path = entry.path();
1011 if path.extension().and_then(|e| e.to_str()) != Some("json") {
1012 continue;
1013 }
1014 if let Ok(content) = fs::read_to_string(&path) {
1015 if let Ok(session) = serde_json::from_str::<ActiveSession>(&content) {
1016 let session_cwd = normalize_path(&session.cwd);
1017 if session_cwd == normalized_project && session.started_at >= threshold_ms {
1018 if best.as_ref().is_none_or(|(_, t)| session.started_at < *t) {
1024 best = Some((session.session_id, session.started_at));
1025 }
1026 }
1027 }
1028 }
1029 }
1030 }
1031
1032 best.map(|(id, _)| id)
1033}
1034
1035fn normalize_path(p: &str) -> String {
1037 p.replace('\\', "/")
1038 .trim_end_matches('/')
1039 .to_lowercase()
1040}
1041
1042pub fn reduce_session(
1052 entries: &[UsageEntry],
1053 launched_model: &str,
1054 session_id_hint: &str,
1055) -> Option<SessionUsage> {
1056 if entries.is_empty() {
1057 return None;
1058 }
1059
1060 let mut input_tokens = 0u64;
1061 let mut output_tokens = 0u64;
1062 let mut cache_read = 0u64;
1063 let mut cache_write = 0u64;
1064 let mut model_counts: HashMap<&str, u64> = HashMap::new();
1065
1066 for e in entries {
1067 input_tokens += e.input_tokens;
1068 output_tokens += e.output_tokens;
1069 cache_read += e.cache_read_tokens;
1070 cache_write += e.cache_write_tokens;
1071 if !e.model.is_empty() {
1072 *model_counts.entry(e.model.as_str()).or_insert(0) += 1;
1073 }
1074 }
1075
1076 let primary_model = model_counts
1077 .into_iter()
1078 .max_by_key(|(_, c)| *c)
1079 .map(|(m, _)| m.to_string())
1080 .unwrap_or_else(|| {
1081 entries
1082 .last()
1083 .map(|e| e.model.clone())
1084 .unwrap_or_default()
1085 });
1086
1087 let estimated_cost: f64 = entries.iter().map(cost_for_entry).sum();
1091
1092 let context_model = if launched_model.is_empty() {
1093 &primary_model
1094 } else {
1095 launched_model
1096 };
1097 let peak_context = entries
1101 .iter()
1102 .map(|e| e.input_tokens + e.cache_read_tokens + e.cache_write_tokens)
1103 .max()
1104 .unwrap_or(0);
1105 let mut context_window = context_window_for_model(context_model);
1106 if peak_context > context_window {
1107 context_window = [200_000, 1_000_000]
1108 .into_iter()
1109 .find(|&w| peak_context <= w)
1110 .unwrap_or(peak_context);
1111 }
1112
1113 let last = entries.last().unwrap();
1114 let last_context_used =
1115 last.input_tokens + last.cache_read_tokens + last.cache_write_tokens;
1116 let context_pct = if context_window > 0 {
1117 (last_context_used as f64 / context_window as f64 * 100.0).min(100.0)
1118 } else {
1119 0.0
1120 };
1121
1122 let total = input_tokens + output_tokens + cache_read + cache_write;
1123 let total_context_sent = input_tokens + cache_read + cache_write;
1124
1125 let cache_denom = input_tokens + cache_read + cache_write;
1130 let cache_hit_pct = if cache_denom > 0 {
1131 (cache_read as f64 / cache_denom as f64) * 100.0
1132 } else {
1133 0.0
1134 };
1135
1136 let session_id = entries
1137 .first()
1138 .map(|e| e.session_id.clone())
1139 .filter(|s| !s.is_empty())
1140 .unwrap_or_else(|| session_id_hint.to_string());
1141
1142 Some(SessionUsage {
1143 model: primary_model,
1144 input_tokens,
1145 output_tokens,
1146 cache_read_tokens: cache_read,
1147 cache_write_tokens: cache_write,
1148 total_tokens: total,
1149 estimated_cost_usd: estimated_cost,
1150 context_window_size: context_window,
1151 context_window_used_pct: context_pct,
1152 current_context_tokens: last_context_used,
1153 total_context_sent,
1154 cache_hit_pct,
1155 message_count: entries.len() as u64,
1156 session_id,
1157 })
1158}
1159
1160#[cfg(test)]
1161mod tests {
1162 use super::*;
1163 use std::io::Write;
1164 use tempfile::TempDir;
1165
1166 #[test]
1167 fn test_encode_project_path_drive_root() {
1168 assert_eq!(encode_project_path("S:\\Loom"), "S--Loom");
1169 }
1170
1171 #[test]
1172 fn test_encode_project_path_deep() {
1173 assert_eq!(
1174 encode_project_path("C:\\Users\\Seven\\Desktop\\SF\\project"),
1175 "C--Users-Seven-Desktop-SF-project"
1176 );
1177 }
1178
1179 #[test]
1180 fn test_encode_project_path_forward_slashes() {
1181 assert_eq!(encode_project_path("S:/Loom"), "S--Loom");
1182 }
1183
1184 #[test]
1185 fn test_encode_project_path_trailing_slash() {
1186 assert_eq!(encode_project_path("S:\\Loom\\"), "S--Loom");
1187 }
1188
1189 #[test]
1190 fn test_encode_project_path_macos() {
1191 assert_eq!(
1192 encode_project_path("/Users/seven/Loom"),
1193 "-Users-seven-Loom"
1194 );
1195 }
1196
1197 #[test]
1198 fn test_encode_project_path_linux() {
1199 assert_eq!(
1200 encode_project_path("/home/alice/projects/foo"),
1201 "-home-alice-projects-foo"
1202 );
1203 }
1204
1205 #[test]
1206 fn test_encode_project_path_unix_trailing_slash() {
1207 assert_eq!(encode_project_path("/Users/seven/Loom/"), "-Users-seven-Loom");
1208 }
1209
1210 #[test]
1212 fn test_encode_project_path_every_non_alphanumeric_becomes_dash() {
1213 assert_eq!(
1214 encode_project_path("C:\\Users\\Seven\\AppData\\Roaming\\com.lloom.app.test\\fixtures\\pong"),
1215 "C--Users-Seven-AppData-Roaming-com-lloom-app-test-fixtures-pong"
1216 );
1217 assert_eq!(
1218 encode_project_path("C:\\Games\\Graal\\VSC\\Era Legacy"),
1219 "C--Games-Graal-VSC-Era-Legacy"
1220 );
1221 assert_eq!(encode_project_path("C:\\Users\\Seven\\.claude"), "C--Users-Seven--claude");
1222 assert_eq!(
1223 encode_project_path("C:\\Users\\Seven\\\u{dc}n\u{ef}code \u{1F600}"),
1224 "C--Users-Seven--n-code---"
1225 );
1226 }
1227
1228 #[test]
1229 fn test_encode_project_path_bare_roots_keep_their_separator() {
1230 assert_eq!(encode_project_path("S:\\"), "S--");
1231 assert_eq!(encode_project_path("/"), "-");
1232 }
1233
1234 #[test]
1235 fn test_encode_project_path_long_names_get_claudes_hash_suffix() {
1236 let path = format!("C:\\Users\\Seven\\{}end", "very-long_folder.name ".repeat(12));
1237 assert_eq!(
1238 encode_project_path(&path),
1239 "C--Users-Seven-very-long-folder-name-very-long-folder-name-very-long-folder-name-\
1240 very-long-folder-name-very-long-folder-name-very-long-folder-name-very-long-folder-\
1241 name-very-long-folder-name-very-long-wel9nd"
1242 );
1243 }
1244
1245 #[test]
1246 fn test_normalize_path() {
1247 assert_eq!(normalize_path("S:\\Loom\\"), "s:/loom");
1248 assert_eq!(normalize_path("S:/Loom"), "s:/loom");
1249 assert_eq!(normalize_path("C:\\Users\\Seven\\"), "c:/users/seven");
1250 }
1251
1252 fn write_jsonl(lines: &[&str]) -> (TempDir, PathBuf) {
1253 let tmp = TempDir::new().unwrap();
1254 let path = tmp.path().join("abc123.jsonl");
1255 let mut f = fs::File::create(&path).unwrap();
1256 for l in lines {
1257 writeln!(f, "{}", l).unwrap();
1258 }
1259 (tmp, path)
1260 }
1261
1262 fn assistant_line(id: &str, model: &str, ts: &str, inp: u64, out: u64, cr: u64, cw: u64) -> String {
1263 format!(
1264 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}}}}}}}"#
1265 )
1266 }
1267
1268 #[test]
1269 fn parse_deduplicates_repeated_message_ids() {
1270 let a1 = assistant_line("msg_01", "claude-opus-4-6", "2026-04-14T08:27:38.895Z", 15, 1463, 16397, 25661);
1273 let a1_dup = a1.clone();
1274 let a2 = assistant_line("msg_02", "claude-opus-4-6", "2026-04-14T08:34:37.200Z", 10, 401, 42058, 1668);
1275 let a2_dup = a2.clone();
1276 let a2_dup2 = a2.clone();
1277 let (_tmp, path) = write_jsonl(&[&a1, &a1_dup, &a2, &a2_dup, &a2_dup2]);
1278
1279 let entries = parse_jsonl_entries(&path).unwrap().entries;
1280 assert_eq!(entries.len(), 2, "should dedup by message.id");
1281 assert_eq!(entries[0].input_tokens, 15);
1282 assert_eq!(entries[0].output_tokens, 1463);
1283 assert_eq!(entries[1].input_tokens, 10);
1284 }
1285
1286 #[test]
1287 fn parse_captures_entrypoint_from_assistant_row() {
1288 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}}}"#;
1292 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}}}"#;
1293 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}}}"#;
1294 let (_tmp, path) = write_jsonl(&[headless, interactive, legacy]);
1295
1296 let entries = parse_jsonl_entries(&path).unwrap().entries;
1297 assert_eq!(entries.len(), 3);
1298 assert_eq!(entries[0].entrypoint.as_deref(), Some("sdk-cli"));
1299 assert_eq!(entries[1].entrypoint.as_deref(), Some("cli"));
1300 assert_eq!(entries[2].entrypoint, None);
1301 }
1302
1303 #[test]
1304 fn parse_skips_non_assistant_entries() {
1305 let lines = [
1306 r#"{"type":"user","message":{"role":"user","content":"hi"}}"#.to_string(),
1307 r#"{"type":"permission-mode"}"#.to_string(),
1308 r#"{"type":"file-history-snapshot"}"#.to_string(),
1309 r#"{"type":"attachment","timestamp":"2026-04-14T08:27:26.236Z"}"#.to_string(),
1310 assistant_line("msg_01", "claude-sonnet-4-6", "2026-04-14T08:28:00.000Z", 100, 200, 0, 0),
1311 ];
1312 let line_refs: Vec<&str> = lines.iter().map(|s| s.as_str()).collect();
1313 let (_tmp, path) = write_jsonl(&line_refs);
1314
1315 let entries = parse_jsonl_entries(&path).unwrap().entries;
1316 assert_eq!(entries.len(), 1);
1317 assert_eq!(entries[0].model, "claude-sonnet-4-6");
1318 }
1319
1320 #[test]
1321 fn parse_tolerates_malformed_lines_and_empty_lines() {
1322 let good = assistant_line("msg_01", "claude-opus-4-6", "2026-04-14T08:27:38.895Z", 5, 10, 0, 0);
1323 let lines = vec![
1324 "",
1325 "not json at all",
1326 r#"{"type":"assistant","message":null}"#,
1327 r#"{"type":"assistant","message":{"id":null}}"#,
1328 &good,
1329 " ",
1330 r#"{"broken":"trailing"#,
1331 ];
1332 let (_tmp, path) = write_jsonl(&lines);
1333
1334 let entries = parse_jsonl_entries(&path).unwrap().entries;
1335 assert_eq!(entries.len(), 1);
1336 }
1337
1338 #[test]
1339 fn parse_extracts_session_id_from_file_stem() {
1340 let line = assistant_line("msg_01", "claude-opus-4-6", "2026-04-14T08:27:38.895Z", 1, 1, 0, 0);
1341 let (_tmp, path) = write_jsonl(&[&line]);
1342 let entries = parse_jsonl_entries(&path).unwrap().entries;
1343 assert_eq!(entries[0].session_id, "abc123");
1344 }
1345
1346 #[test]
1347 fn parse_parses_iso_timestamps() {
1348 let line = assistant_line("msg_01", "claude-opus-4-6", "2026-04-14T08:27:38.895Z", 1, 1, 0, 0);
1349 let (_tmp, path) = write_jsonl(&[&line]);
1350 let entries = parse_jsonl_entries(&path).unwrap().entries;
1351 assert_eq!(entries[0].timestamp_ms, 1_776_155_258_895);
1353 }
1354
1355 #[test]
1356 fn parse_skips_synthetic_model_rows() {
1357 let real = assistant_line("msg_real", "claude-opus-4-6", "2026-04-11T00:27:38.895Z", 15, 100, 0, 0);
1360 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}}}"#;
1361 let (_tmp, path) = write_jsonl(&[&real, synthetic]);
1362 let entries = parse_jsonl_entries(&path).unwrap().entries;
1363 assert_eq!(entries.len(), 1);
1364 assert_eq!(entries[0].model, "claude-opus-4-6");
1365 }
1366
1367 #[test]
1368 fn parse_handles_missing_usage_fields() {
1369 let line = r#"{"type":"assistant","timestamp":"2026-04-14T08:27:38.895Z","message":{"id":"msg_01","model":"claude-opus-4-6"}}"#;
1370 let (_tmp, path) = write_jsonl(&[line]);
1371 let entries = parse_jsonl_entries(&path).unwrap().entries;
1372 assert_eq!(entries.len(), 1);
1373 assert_eq!(entries[0].input_tokens, 0);
1374 assert_eq!(entries[0].cache_read_tokens, 0);
1375 }
1376
1377 #[test]
1378 fn reduce_computes_primary_model_by_mode() {
1379 let e = |m: &str| UsageEntry {
1380 session_id: "s".into(),
1381 timestamp_ms: 0,
1382 model: m.into(),
1383 input_tokens: 1,
1384 output_tokens: 1,
1385 cache_read_tokens: 0,
1386 cache_write_tokens: 0,
1387 cache_write_1h_tokens: 0,
1388 web_search_requests: 0,
1389 speed: None,
1390 tool_uses: Vec::new(),
1391 text_chars: 0,
1392 activity: Activity::General,
1393 entrypoint: None,
1394 };
1395 let entries = vec![
1396 e("claude-opus-4-6"),
1397 e("claude-opus-4-6"),
1398 e("claude-opus-4-6"),
1399 e("claude-sonnet-4-6"),
1400 ];
1401 let u = reduce_session(&entries, "", "sid").unwrap();
1402 assert_eq!(u.model, "claude-opus-4-6");
1403 assert_eq!(u.message_count, 4);
1404 }
1405
1406 #[test]
1407 fn reduce_uses_launched_model_for_context_window() {
1408 let entries = vec![UsageEntry {
1409 session_id: "s".into(),
1410 timestamp_ms: 0,
1411 model: "claude-opus-4-6".into(),
1413 input_tokens: 1000,
1414 output_tokens: 0,
1415 cache_read_tokens: 0,
1416 cache_write_tokens: 0,
1417 cache_write_1h_tokens: 0,
1418 web_search_requests: 0,
1419 speed: None,
1420 tool_uses: Vec::new(),
1421 text_chars: 0,
1422 activity: Activity::General,
1423 entrypoint: None,
1424 }];
1425 let with_1m = reduce_session(&entries, "claude-opus-4-6[1m]", "sid").unwrap();
1426 let without = reduce_session(&entries, "", "sid").unwrap();
1427 assert_eq!(with_1m.context_window_size, 1_000_000);
1428 assert_eq!(without.context_window_size, 200_000);
1429 }
1430
1431 #[test]
1432 fn bare_opus_and_sonnet_5_plus_are_natively_1m() {
1433 for m in [
1434 "claude-opus-5-5",
1435 "claude-opus-5",
1436 "claude-sonnet-5",
1437 "claude-sonnet-5-5",
1438 "claude-opus-5-5[1m]",
1439 ] {
1440 assert_eq!(context_window_for_model(m), 1_000_000, "{m}");
1441 }
1442 for m in ["claude-opus-4-8", "claude-sonnet-4-6", "claude-haiku-4-5-20251001"] {
1443 assert_eq!(context_window_for_model(m), 200_000, "{m}");
1444 }
1445 }
1446
1447 #[test]
1448 fn reduce_widens_window_to_hold_peak_context() {
1449 let mk = |input: u64, cr: u64| UsageEntry {
1450 session_id: "s".into(),
1451 timestamp_ms: 0,
1452 model: "claude-opus-4-8".into(),
1453 input_tokens: input,
1454 output_tokens: 0,
1455 cache_read_tokens: cr,
1456 cache_write_tokens: 0,
1457 cache_write_1h_tokens: 0,
1458 web_search_requests: 0,
1459 speed: None,
1460 tool_uses: Vec::new(),
1461 text_chars: 0,
1462 activity: Activity::General,
1463 entrypoint: None,
1464 };
1465 let entries = vec![mk(10, 250_000), mk(10, 99_990)];
1467 let u = reduce_session(&entries, "claude-opus-4-8", "sid").unwrap();
1468 assert_eq!(u.context_window_size, 1_000_000);
1469 assert_eq!(u.current_context_tokens, 100_000);
1470 assert!((u.context_window_used_pct - 10.0).abs() < 0.01);
1471 }
1472
1473 #[test]
1474 fn reduce_context_pct_uses_last_entry_only() {
1475 let mk = |input: u64, cr: u64, cw: u64| UsageEntry {
1477 session_id: "s".into(),
1478 timestamp_ms: 0,
1479 model: "claude-opus-4-6".into(),
1480 input_tokens: input,
1481 output_tokens: 0,
1482 cache_read_tokens: cr,
1483 cache_write_tokens: cw,
1484 cache_write_1h_tokens: 0,
1485 web_search_requests: 0,
1486 speed: None,
1487 tool_uses: Vec::new(),
1488 text_chars: 0,
1489 activity: Activity::General,
1490 entrypoint: None,
1491 };
1492 let entries = vec![
1493 mk(1000, 0, 0),
1494 mk(500, 0, 0),
1495 mk(5, 160_000, 0), ];
1497 let u = reduce_session(&entries, "", "sid").unwrap();
1498 assert_eq!(u.current_context_tokens, 160_005);
1499 assert!((u.context_window_used_pct - 80.0025).abs() < 0.01);
1500 }
1501
1502 #[test]
1503 fn reduce_cache_hit_pct() {
1504 let e = |input: u64, cr: u64, cw: u64| UsageEntry {
1505 session_id: "s".into(),
1506 timestamp_ms: 0,
1507 model: "claude-opus-4-6".into(),
1508 input_tokens: input,
1509 output_tokens: 1,
1510 cache_read_tokens: cr,
1511 cache_write_tokens: cw,
1512 cache_write_1h_tokens: 0,
1513 web_search_requests: 0,
1514 speed: None,
1515 tool_uses: Vec::new(),
1516 text_chars: 0,
1517 activity: Activity::General,
1518 entrypoint: None,
1519 };
1520 let u = reduce_session(&[e(3, 97, 0)], "", "sid").unwrap();
1522 assert!((u.cache_hit_pct - 97.0).abs() < 0.01);
1523
1524 let u = reduce_session(&[e(10, 80, 10)], "", "sid").unwrap();
1526 assert!((u.cache_hit_pct - 80.0).abs() < 0.01);
1527
1528 let u = reduce_session(&[e(0, 0, 0)], "", "sid").unwrap();
1530 assert_eq!(u.cache_hit_pct, 0.0);
1531 }
1532
1533 #[test]
1534 fn reduce_total_context_sent_is_cumulative() {
1535 let mk = |input: u64, cr: u64, cw: u64| UsageEntry {
1536 session_id: "s".into(),
1537 timestamp_ms: 0,
1538 model: "claude-opus-4-6".into(),
1539 input_tokens: input,
1540 output_tokens: 0,
1541 cache_read_tokens: cr,
1542 cache_write_tokens: cw,
1543 cache_write_1h_tokens: 0,
1544 web_search_requests: 0,
1545 speed: None,
1546 tool_uses: Vec::new(),
1547 text_chars: 0,
1548 activity: Activity::General,
1549 entrypoint: None,
1550 };
1551 let u = reduce_session(
1552 &[mk(15, 16_397, 25_661), mk(10, 42_058, 1_668)],
1553 "",
1554 "sid",
1555 )
1556 .unwrap();
1557 assert_eq!(u.total_context_sent, 15 + 16_397 + 25_661 + 10 + 42_058 + 1_668);
1558 }
1559
1560 #[test]
1561 fn reduce_cost_matches_opus_pricing() {
1562 let entries = vec![UsageEntry {
1564 session_id: "s".into(),
1565 timestamp_ms: 0,
1566 model: "claude-opus-4-6".into(),
1567 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,
1572 web_search_requests: 0,
1573 speed: None,
1574 tool_uses: Vec::new(),
1575 text_chars: 0,
1576 activity: Activity::General,
1577 entrypoint: None,
1578 }];
1579 let u = reduce_session(&entries, "", "sid").unwrap();
1580 assert!((u.estimated_cost_usd - 36.75).abs() < 0.001);
1581 }
1582
1583 #[test]
1584 fn reduce_empty_returns_none() {
1585 assert!(reduce_session(&[], "", "sid").is_none());
1586 }
1587
1588 fn assistant_with_content(id: &str, ts: &str, content_json: &str) -> String {
1591 format!(
1592 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}}}}}"#
1593 )
1594 }
1595
1596 #[test]
1597 fn parse_extracts_tool_use_names() {
1598 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"}}]"#;
1599 let line = assistant_with_content("msg_1", "2026-04-14T08:27:38.895Z", content);
1600 let (_tmp, path) = write_jsonl(&[&line]);
1601 let entries = parse_jsonl_entries(&path).unwrap().entries;
1602 assert_eq!(entries.len(), 1);
1603 assert_eq!(entries[0].tool_uses, vec!["Edit".to_string(), "Read".to_string()]);
1604 }
1605
1606 #[test]
1607 fn parse_captures_text_char_count() {
1608 let content = r#"[{"type":"text","text":"Hello world"},{"type":"text","text":"!"}]"#;
1609 let line = assistant_with_content("msg_1", "2026-04-14T08:27:38.895Z", content);
1610 let (_tmp, path) = write_jsonl(&[&line]);
1611 let entries = parse_jsonl_entries(&path).unwrap().entries;
1612 assert_eq!(entries[0].text_chars, 12); }
1614
1615 #[test]
1616 fn parse_handles_missing_content_gracefully() {
1617 let line = assistant_line("msg_1", "claude-opus-4-6", "2026-04-14T08:27:38.895Z", 5, 10, 0, 0);
1620 let (_tmp, path) = write_jsonl(&[&line]);
1621 let entries = parse_jsonl_entries(&path).unwrap().entries;
1622 assert_eq!(entries.len(), 1);
1623 assert!(entries[0].tool_uses.is_empty());
1624 assert_eq!(entries[0].text_chars, 0);
1625 assert_eq!(entries[0].activity, Activity::General);
1626 }
1627
1628 #[test]
1629 fn parse_classifies_edit_plus_debug_as_debugging() {
1630 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();
1632 let content = r#"[{"type":"tool_use","id":"tu_1","name":"Edit","input":{"file_path":"login.rs"}}]"#;
1633 let asst_line = assistant_with_content("msg_1", "2026-04-14T08:27:38.895Z", content);
1634 let (_tmp, path) = write_jsonl(&[&user_line, &asst_line]);
1635 let entries = parse_jsonl_entries(&path).unwrap().entries;
1636 assert_eq!(entries.len(), 1);
1637 assert_eq!(entries[0].activity, Activity::Debugging);
1638 }
1639
1640 #[test]
1641 fn parse_classifies_bash_pytest_as_testing() {
1642 let user_line = r#"{"type":"user","timestamp":"2026-04-14T08:27:30.000Z","message":{"role":"user","content":"run the tests"}}"#.to_string();
1643 let content = r#"[{"type":"tool_use","id":"tu_1","name":"Bash","input":{"command":"pytest tests/unit"}}]"#;
1644 let asst_line = assistant_with_content("msg_1", "2026-04-14T08:27:38.895Z", content);
1645 let (_tmp, path) = write_jsonl(&[&user_line, &asst_line]);
1646 let entries = parse_jsonl_entries(&path).unwrap().entries;
1647 assert_eq!(entries[0].activity, Activity::Testing);
1648 }
1649
1650 #[test]
1651 fn parse_classifies_bash_git_as_gitops() {
1652 let content = r#"[{"type":"tool_use","id":"tu_1","name":"Bash","input":{"command":"git push origin main"}}]"#;
1653 let line = assistant_with_content("msg_1", "2026-04-14T08:27:38.895Z", content);
1654 let (_tmp, path) = write_jsonl(&[&line]);
1655 let entries = parse_jsonl_entries(&path).unwrap().entries;
1656 assert_eq!(entries[0].activity, Activity::GitOps);
1657 }
1658
1659 #[test]
1660 fn parse_tool_result_row_does_not_overwrite_user_text() {
1661 let user_real = r#"{"type":"user","timestamp":"2026-04-14T08:27:30.000Z","message":{"role":"user","content":"refactor the usage store"}}"#.to_string();
1666 let asst1 = assistant_with_content(
1667 "msg_1",
1668 "2026-04-14T08:27:35.000Z",
1669 r#"[{"type":"tool_use","id":"tu_1","name":"Read","input":{"file_path":"store.ts"}}]"#,
1670 );
1671 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();
1672 let asst2 = assistant_with_content(
1673 "msg_2",
1674 "2026-04-14T08:27:40.000Z",
1675 r#"[{"type":"tool_use","id":"tu_2","name":"Edit","input":{"file_path":"store.ts"}}]"#,
1676 );
1677 let (_tmp, path) = write_jsonl(&[&user_real, &asst1, &tool_result, &asst2]);
1678 let entries = parse_jsonl_entries(&path).unwrap().entries;
1679 assert_eq!(entries.len(), 2);
1680 assert_eq!(entries[0].activity, Activity::Exploration);
1682 assert_eq!(entries[1].activity, Activity::Refactor);
1685 }
1686
1687 #[test]
1688 fn parse_classifies_agent_turn_as_delegation() {
1689 let content = r#"[{"type":"tool_use","id":"tu_1","name":"Agent","input":{"description":"explore"}}]"#;
1690 let line = assistant_with_content("msg_1", "2026-04-14T08:27:38.895Z", content);
1691 let (_tmp, path) = write_jsonl(&[&line]);
1692 let entries = parse_jsonl_entries(&path).unwrap().entries;
1693 assert_eq!(entries[0].activity, Activity::Delegation);
1694 }
1695
1696 #[test]
1697 fn parse_pure_text_turn_is_conversation() {
1698 let user_line = r#"{"type":"user","timestamp":"2026-04-14T08:27:30.000Z","message":{"role":"user","content":"thanks"}}"#.to_string();
1699 let content = r#"[{"type":"text","text":"You're welcome — anything else?"}]"#;
1700 let asst_line = assistant_with_content("msg_1", "2026-04-14T08:27:38.895Z", content);
1701 let (_tmp, path) = write_jsonl(&[&user_line, &asst_line]);
1702 let entries = parse_jsonl_entries(&path).unwrap().entries;
1703 assert_eq!(entries[0].activity, Activity::Conversation);
1704 assert!(entries[0].text_chars > 0);
1705 }
1706
1707 #[test]
1708 fn parse_user_array_content_extracts_text_blocks() {
1709 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();
1712 let content = r#"[{"type":"tool_use","id":"tu_1","name":"Edit","input":{}}]"#;
1713 let asst_line = assistant_with_content("msg_1", "2026-04-14T08:27:38.895Z", content);
1714 let (_tmp, path) = write_jsonl(&[&user_line, &asst_line]);
1715 let entries = parse_jsonl_entries(&path).unwrap().entries;
1716 assert_eq!(entries[0].activity, Activity::Debugging);
1717 }
1718
1719 fn assistant_split_row(id: &str, ts: &str, content_json: &str) -> String {
1725 format!(
1726 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}}}}}"#
1727 )
1728 }
1729
1730 #[test]
1731 fn parse_merges_tool_uses_across_split_content_rows() {
1732 let thinking = assistant_split_row(
1738 "msg_01M6AvUVpAFjzSWcXeyGzUwV",
1739 "2026-05-13T09:09:03.500Z",
1740 r#"[{"type":"thinking","thinking":"plan"}]"#,
1741 );
1742 let text = assistant_split_row(
1743 "msg_01M6AvUVpAFjzSWcXeyGzUwV",
1744 "2026-05-13T09:09:03.550Z",
1745 r#"[{"type":"text","text":"Running tools in parallel."}]"#,
1746 );
1747 let tu1 = assistant_split_row(
1748 "msg_01M6AvUVpAFjzSWcXeyGzUwV",
1749 "2026-05-13T09:09:03.600Z",
1750 r#"[{"type":"tool_use","id":"tu_1","name":"mcp__example__list_configs","input":{"area":"rules"}}]"#,
1751 );
1752 let tu2 = assistant_split_row(
1753 "msg_01M6AvUVpAFjzSWcXeyGzUwV",
1754 "2026-05-13T09:09:03.700Z",
1755 r#"[{"type":"tool_use","id":"tu_2","name":"mcp__example__get_report","input":{}}]"#,
1756 );
1757 let tu3 = assistant_split_row(
1758 "msg_01M6AvUVpAFjzSWcXeyGzUwV",
1759 "2026-05-13T09:09:03.800Z",
1760 r#"[{"type":"tool_use","id":"tu_3","name":"Edit","input":{"file_path":"a.rs"}}]"#,
1761 );
1762 let (_tmp, path) = write_jsonl(&[&thinking, &text, &tu1, &tu2, &tu3]);
1763
1764 let entries = parse_jsonl_entries(&path).unwrap().entries;
1765 assert_eq!(entries.len(), 1, "still one entry per message.id");
1766
1767 let e = &entries[0];
1768 assert_eq!(
1769 e.tool_uses,
1770 vec![
1771 "mcp__example__list_configs".to_string(),
1772 "mcp__example__get_report".to_string(),
1773 "Edit".to_string(),
1774 ],
1775 "all tool_use names merged in row order"
1776 );
1777 assert!(e.text_chars > 0, "text from the text row is preserved");
1778
1779 assert_eq!(e.input_tokens, 6);
1783 assert_eq!(e.output_tokens, 746);
1784 assert_eq!(e.cache_read_tokens, 21859);
1785 assert_eq!(e.cache_write_tokens, 33027);
1786 }
1787
1788 #[test]
1789 fn parse_split_row_classifies_against_merged_content() {
1790 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();
1795 let thinking = assistant_split_row(
1796 "msg_split",
1797 "2026-05-13T09:09:01.000Z",
1798 r#"[{"type":"thinking","thinking":"investigating"}]"#,
1799 );
1800 let edit = assistant_split_row(
1801 "msg_split",
1802 "2026-05-13T09:09:02.000Z",
1803 r#"[{"type":"tool_use","id":"tu_1","name":"Edit","input":{"file_path":"auth.rs"}}]"#,
1804 );
1805 let (_tmp, path) = write_jsonl(&[&user, &thinking, &edit]);
1806
1807 let entries = parse_jsonl_entries(&path).unwrap().entries;
1808 assert_eq!(entries.len(), 1);
1809 assert_eq!(entries[0].activity, Activity::Debugging);
1810 assert_eq!(entries[0].tool_uses, vec!["Edit".to_string()]);
1811 }
1812
1813 #[test]
1814 fn parse_split_row_bash_commands_feed_classifier() {
1815 let user = r#"{"type":"user","timestamp":"2026-05-13T09:09:00.000Z","message":{"role":"user","content":"run tests"}}"#.to_string();
1818 let text = assistant_split_row(
1819 "msg_bash",
1820 "2026-05-13T09:09:01.000Z",
1821 r#"[{"type":"text","text":"Running the unit tests."}]"#,
1822 );
1823 let bash = assistant_split_row(
1824 "msg_bash",
1825 "2026-05-13T09:09:02.000Z",
1826 r#"[{"type":"tool_use","id":"tu_1","name":"Bash","input":{"command":"pytest tests/"}}]"#,
1827 );
1828 let (_tmp, path) = write_jsonl(&[&user, &text, &bash]);
1829
1830 let entries = parse_jsonl_entries(&path).unwrap().entries;
1831 assert_eq!(entries[0].activity, Activity::Testing);
1832 }
1833
1834 #[test]
1837 fn parse_extracts_hook_success_events() {
1838 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}}"#;
1839 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}}"#;
1840 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"}}"#;
1842 let asst = assistant_line("msg_01", "claude-opus-4-6", "2026-04-15T18:08:51.000Z", 5, 10, 0, 0);
1843 let (_tmp, path) = write_jsonl(&[hook_line, hook_fail, hook_msg, &asst]);
1844
1845 let parsed = parse_jsonl_entries(&path).unwrap();
1846 assert_eq!(parsed.entries.len(), 1, "assistant entries unaffected");
1847 assert_eq!(parsed.hooks.len(), 2, "two hook_success events");
1848
1849 assert_eq!(parsed.hooks[0].hook_name, "PostToolUse:Edit");
1850 assert_eq!(parsed.hooks[0].hook_event, "PostToolUse");
1851 assert_eq!(parsed.hooks[0].command, "verify-edit.sh");
1852 assert_eq!(parsed.hooks[0].exit_code, 0);
1853 assert_eq!(parsed.hooks[0].duration_ms, 90);
1854
1855 assert_eq!(parsed.hooks[0].tool_use_id.as_deref(), Some("tu_abc"));
1856
1857 assert_eq!(parsed.hooks[1].hook_name, "PreToolUse:Bash");
1858 assert_eq!(parsed.hooks[1].exit_code, 1);
1859 assert_eq!(parsed.hooks[1].duration_ms, 250);
1860 assert_eq!(parsed.hooks[1].tool_use_id.as_deref(), Some("tu_def"));
1861 }
1862
1863 #[test]
1864 fn parse_ignores_attachment_without_hook_data() {
1865 let non_hook = r#"{"type":"attachment","timestamp":"2026-04-14T08:27:26.236Z","attachment":{"type":"file_content","path":"foo.rs"}}"#;
1867 let asst = assistant_line("msg_01", "claude-opus-4-6", "2026-04-14T08:28:00.000Z", 1, 1, 0, 0);
1868 let (_tmp, path) = write_jsonl(&[non_hook, &asst]);
1869
1870 let parsed = parse_jsonl_entries(&path).unwrap();
1871 assert_eq!(parsed.entries.len(), 1);
1872 assert!(parsed.hooks.is_empty());
1873 }
1874
1875 #[test]
1876 fn snake_to_pascal_converts_correctly() {
1877 assert_eq!(snake_to_pascal("stop"), "Stop");
1878 assert_eq!(snake_to_pascal("pre_tool_use"), "PreToolUse");
1879 assert_eq!(snake_to_pascal("post_tool_use"), "PostToolUse");
1880 assert_eq!(snake_to_pascal("session_start"), "SessionStart");
1881 }
1882
1883 #[test]
1884 fn parse_extracts_system_hook_summary_v2() {
1885 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"}"#;
1887 let asst = assistant_line("msg_01", "claude-opus-4-6", "2026-04-25T09:06:13.000Z", 5, 10, 0, 0);
1888 let (_tmp, path) = write_jsonl(&[stop_summary, &asst]);
1889
1890 let parsed = parse_jsonl_entries(&path).unwrap();
1891 assert_eq!(parsed.entries.len(), 1);
1892 assert_eq!(parsed.hooks.len(), 1);
1893
1894 let h = &parsed.hooks[0];
1895 assert_eq!(h.hook_event, "Stop");
1896 assert_eq!(h.command, "Running build checks...");
1897 assert_eq!(h.duration_ms, 5227);
1898 assert_eq!(h.exit_code, 0, "no errors and not prevented = success");
1899 assert_eq!(h.tool_use_id.as_deref(), Some("tu_xyz"));
1900 }
1901
1902 #[test]
1903 fn parse_system_hook_summary_with_errors() {
1904 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"}"#;
1905 let asst = assistant_line("msg_02", "claude-opus-4-6", "2026-04-25T10:00:01.000Z", 1, 1, 0, 0);
1906 let (_tmp, path) = write_jsonl(&[summary, &asst]);
1907
1908 let parsed = parse_jsonl_entries(&path).unwrap();
1909 assert_eq!(parsed.hooks.len(), 1);
1910 assert_eq!(parsed.hooks[0].exit_code, 1, "errors or prevented = failure");
1911 }
1912
1913 #[test]
1914 fn parse_system_hook_summary_multiple_hooks() {
1915 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"}"#;
1916 let asst = assistant_line("msg_03", "claude-opus-4-6", "2026-04-25T11:00:01.000Z", 1, 1, 0, 0);
1917 let (_tmp, path) = write_jsonl(&[summary, &asst]);
1918
1919 let parsed = parse_jsonl_entries(&path).unwrap();
1920 assert_eq!(parsed.hooks.len(), 2);
1921 assert_eq!(parsed.hooks[0].command, "tsc --noEmit");
1922 assert_eq!(parsed.hooks[0].duration_ms, 1200);
1923 assert_eq!(parsed.hooks[1].command, "cargo check");
1924 assert_eq!(parsed.hooks[1].duration_ms, 3500);
1925 assert_eq!(parsed.hooks[0].tool_use_id, parsed.hooks[1].tool_use_id);
1926 }
1927
1928 #[test]
1929 fn hook_name_label_sanitizes_long_multiline_commands() {
1930 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.";
1934 let escaped = serde_json::to_string(long_cmd).unwrap();
1935 let summary = format!(
1936 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"}}"#
1937 );
1938 let asst = assistant_line("msg_goal", "claude-opus-4-6", "2026-04-25T12:00:01.000Z", 1, 1, 0, 0);
1939 let (_tmp, path) = write_jsonl(&[&summary, &asst]);
1940
1941 let parsed = parse_jsonl_entries(&path).unwrap();
1942 assert_eq!(parsed.hooks.len(), 1);
1943 let h = &parsed.hooks[0];
1944
1945 assert_eq!(h.command, long_cmd, "full command preserved on the entry");
1946 assert!(h.hook_name.starts_with("Stop:"), "name still scoped by event");
1947 assert!(h.hook_name.chars().count() <= "Stop:".len() + 65);
1949 assert!(h.hook_name.ends_with('…'), "long commands get ellipsis: {}", h.hook_name);
1950 assert!(!h.hook_name.contains('\n'), "no newlines in bucket key");
1951 }
1952
1953 #[test]
1954 fn hook_name_label_preserves_short_commands() {
1955 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"}"#;
1956 let asst = assistant_line("msg_short", "claude-opus-4-6", "2026-04-25T13:00:01.000Z", 1, 1, 0, 0);
1957 let (_tmp, path) = write_jsonl(&[summary, &asst]);
1958
1959 let parsed = parse_jsonl_entries(&path).unwrap();
1960 assert_eq!(parsed.hooks[0].hook_name, "Stop:cargo check");
1961 }
1962
1963 #[test]
1964 fn parse_handles_both_formats_in_same_file() {
1965 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}}"#;
1967 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"}"#;
1969 let asst = assistant_line("msg_both", "claude-opus-4-6", "2026-04-25T09:07:00.000Z", 1, 1, 0, 0);
1970 let (_tmp, path) = write_jsonl(&[old_hook, new_hook, &asst]);
1971
1972 let parsed = parse_jsonl_entries(&path).unwrap();
1973 assert_eq!(parsed.hooks.len(), 2, "both formats parsed");
1974 assert_eq!(parsed.hooks[0].hook_event, "PostToolUse");
1975 assert_eq!(parsed.hooks[1].hook_event, "Stop");
1976 }
1977
1978 #[allow(clippy::too_many_arguments)]
1981 fn entry_for_cost(
1982 model: &str,
1983 input: u64,
1984 output: u64,
1985 cr: u64,
1986 cw_total: u64,
1987 cw_1h: u64,
1988 web: u64,
1989 speed: Option<&str>,
1990 ) -> UsageEntry {
1991 UsageEntry {
1992 session_id: "s".into(),
1993 timestamp_ms: 0,
1994 model: model.into(),
1995 input_tokens: input,
1996 output_tokens: output,
1997 cache_read_tokens: cr,
1998 cache_write_tokens: cw_total,
1999 cache_write_1h_tokens: cw_1h,
2000 web_search_requests: web,
2001 speed: speed.map(|s| s.to_string()),
2002 tool_uses: Vec::new(),
2003 text_chars: 0,
2004 activity: Activity::General,
2005 entrypoint: None,
2006 }
2007 }
2008
2009 #[test]
2010 fn fable_pricing_all_token_classes() {
2011 let input = entry_for_cost("claude-fable-5", 1_000_000, 0, 0, 0, 0, 0, None);
2013 assert!((cost_for_entry(&input) - 10.0).abs() < 1e-6);
2014 let output = entry_for_cost("claude-fable-5", 0, 1_000_000, 0, 0, 0, 0, None);
2015 assert!((cost_for_entry(&output) - 50.0).abs() < 1e-6);
2016 let read = entry_for_cost("claude-fable-5", 0, 0, 1_000_000, 0, 0, 0, None);
2017 assert!((cost_for_entry(&read) - 1.0).abs() < 1e-6);
2018 let w5m = entry_for_cost("claude-fable-5", 0, 0, 0, 1_000_000, 0, 0, None);
2019 assert!((cost_for_entry(&w5m) - 12.5).abs() < 1e-6);
2020 let w1h = entry_for_cost("claude-fable-5", 0, 0, 0, 1_000_000, 1_000_000, 0, None);
2021 assert!((cost_for_entry(&w1h) - 20.0).abs() < 1e-6);
2022 }
2023
2024 #[test]
2025 fn fable_has_no_fast_premium() {
2026 let fast = entry_for_cost("claude-fable-5", 1_000_000, 0, 0, 0, 0, 0, Some("fast"));
2029 assert!((cost_for_entry(&fast) - 10.0).abs() < 1e-6);
2030 }
2031
2032 #[test]
2033 fn fable_context_window_is_natively_1m() {
2034 assert_eq!(context_window_for_model("claude-fable-5"), 1_000_000);
2036 assert_eq!(context_window_for_model("claude-opus-4-8"), 200_000);
2037 assert_eq!(context_window_for_model("claude-opus-4-8[1m]"), 1_000_000);
2038 }
2039
2040 #[test]
2041 fn cost_splits_5m_and_1h_cache_writes() {
2042 let all_1h = entry_for_cost("claude-opus-4-8", 0, 0, 0, 1_000_000, 1_000_000, 0, None);
2044 assert!((cost_for_entry(&all_1h) - 10.0).abs() < 1e-6);
2045 let all_5m = entry_for_cost("claude-opus-4-8", 0, 0, 0, 1_000_000, 0, 0, None);
2046 assert!((cost_for_entry(&all_5m) - 6.25).abs() < 1e-6);
2047 let mixed = entry_for_cost("claude-opus-4-8", 0, 0, 0, 1_000_000, 400_000, 0, None);
2049 assert!((cost_for_entry(&mixed) - 7.75).abs() < 1e-6);
2050 }
2051
2052 #[test]
2053 fn cost_adds_flat_web_search_fee() {
2054 let e = entry_for_cost("claude-sonnet-4-6", 0, 0, 0, 0, 0, 3, None);
2056 assert!((cost_for_entry(&e) - 0.03).abs() < 1e-9);
2057 }
2058
2059 #[test]
2060 fn fast_mode_multiplier_scales_token_cost_not_web_fee() {
2061 let std = entry_for_cost("claude-opus-4-8", 1_000_000, 0, 0, 0, 0, 0, Some("standard"));
2063 assert!((cost_for_entry(&std) - 5.0).abs() < 1e-6);
2064 let fast = entry_for_cost("claude-opus-4-8", 1_000_000, 0, 0, 0, 0, 0, Some("fast"));
2065 assert!((cost_for_entry(&fast) - 10.0).abs() < 1e-6);
2066 let fast46 = entry_for_cost("claude-opus-4-6", 1_000_000, 0, 0, 0, 0, 0, Some("fast"));
2068 assert!((cost_for_entry(&fast46) - 30.0).abs() < 1e-6);
2069 let fast_web = entry_for_cost("claude-opus-4-8", 1_000_000, 0, 0, 0, 0, 1, Some("fast"));
2071 assert!((cost_for_entry(&fast_web) - 10.01).abs() < 1e-6);
2072 }
2073
2074 #[test]
2075 fn fast_multiplier_only_for_known_opus() {
2076 assert_eq!(fast_multiplier_for_model("claude-opus-4-8"), 2.0);
2077 assert_eq!(fast_multiplier_for_model("claude-opus-4-7"), 6.0);
2078 assert_eq!(fast_multiplier_for_model("claude-opus-4-6"), 6.0);
2079 assert_eq!(fast_multiplier_for_model("claude-opus-5"), 2.0);
2080 assert_eq!(fast_multiplier_for_model("claude-opus-5[1m]"), 2.0);
2081 assert_eq!(fast_multiplier_for_model("claude-opus-5-5"), 2.0);
2082 assert_eq!(fast_multiplier_for_model("claude-opus-5-5[1m]"), 2.0);
2083 assert_eq!(fast_multiplier_for_model("claude-sonnet-4-6"), 1.0);
2084 assert_eq!(fast_multiplier_for_model("claude-fable-5-1"), 1.0);
2085 assert_eq!(fast_multiplier_for_model("claude-opus-6"), 1.0);
2087 }
2088
2089 #[test]
2090 fn fable_5_1_reads_cache_at_a_quarter_of_fable_5() {
2091 let read51 = entry_for_cost("claude-fable-5-1", 0, 0, 1_000_000, 0, 0, 0, None);
2094 assert!((cost_for_entry(&read51) - 0.25).abs() < 1e-6);
2095 let read5 = entry_for_cost("claude-fable-5", 0, 0, 1_000_000, 0, 0, 0, None);
2096 assert!((cost_for_entry(&read5) - 1.0).abs() < 1e-6);
2097
2098 let input = entry_for_cost("claude-fable-5-1", 1_000_000, 0, 0, 0, 0, 0, None);
2099 assert!((cost_for_entry(&input) - 10.0).abs() < 1e-6);
2100 let output = entry_for_cost("claude-fable-5-1", 0, 1_000_000, 0, 0, 0, 0, None);
2101 assert!((cost_for_entry(&output) - 50.0).abs() < 1e-6);
2102 let w5m = entry_for_cost("claude-fable-5-1", 0, 0, 0, 1_000_000, 0, 0, None);
2103 assert!((cost_for_entry(&w5m) - 12.5).abs() < 1e-6);
2104 let w1h = entry_for_cost("claude-fable-5-1", 0, 0, 0, 1_000_000, 1_000_000, 0, None);
2105 assert!((cost_for_entry(&w1h) - 20.0).abs() < 1e-6);
2106 }
2107
2108 #[test]
2109 fn opus_5_5_is_cheaper_than_opus_5() {
2110 let in55 = entry_for_cost("claude-opus-5-5", 1_000_000, 0, 0, 0, 0, 0, None);
2113 assert!((cost_for_entry(&in55) - 4.0).abs() < 1e-6);
2114 let out55 = entry_for_cost("claude-opus-5-5", 0, 1_000_000, 0, 0, 0, 0, None);
2115 assert!((cost_for_entry(&out55) - 20.0).abs() < 1e-6);
2116 let read55 = entry_for_cost("claude-opus-5-5", 0, 0, 1_000_000, 0, 0, 0, None);
2117 assert!((cost_for_entry(&read55) - 0.2).abs() < 1e-6);
2118 let w5m = entry_for_cost("claude-opus-5-5", 0, 0, 0, 1_000_000, 0, 0, None);
2119 assert!((cost_for_entry(&w5m) - 5.0).abs() < 1e-6);
2120 let w1h = entry_for_cost("claude-opus-5-5", 0, 0, 0, 1_000_000, 1_000_000, 0, None);
2121 assert!((cost_for_entry(&w1h) - 8.0).abs() < 1e-6);
2122
2123 let in55_1m = entry_for_cost("claude-opus-5-5[1m]", 1_000_000, 0, 0, 0, 0, 0, None);
2125 assert!((cost_for_entry(&in55_1m) - 4.0).abs() < 1e-6);
2126 let fast = entry_for_cost("claude-opus-5-5", 1_000_000, 0, 0, 0, 0, 0, Some("fast"));
2127 assert!((cost_for_entry(&fast) - 8.0).abs() < 1e-6);
2128
2129 let in5 = entry_for_cost("claude-opus-5", 1_000_000, 0, 0, 0, 0, 0, None);
2131 assert!((cost_for_entry(&in5) - 5.0).abs() < 1e-6);
2132 assert_eq!(pricing_for_model("opus").input, 5.0);
2133 assert_eq!(pricing_for_model("opus").cache_read, 0.5);
2134 }
2135
2136 #[test]
2137 fn sonnet_5_is_cheaper_than_sonnet_4_6() {
2138 let in5 = entry_for_cost("claude-sonnet-5", 1_000_000, 0, 0, 0, 0, 0, None);
2141 assert!((cost_for_entry(&in5) - 2.0).abs() < 1e-6);
2142 let out5 = entry_for_cost("claude-sonnet-5", 0, 1_000_000, 0, 0, 0, 0, None);
2143 assert!((cost_for_entry(&out5) - 10.0).abs() < 1e-6);
2144 let read5 = entry_for_cost("claude-sonnet-5", 0, 0, 1_000_000, 0, 0, 0, None);
2145 assert!((cost_for_entry(&read5) - 0.2).abs() < 1e-6);
2146 let w5m = entry_for_cost("claude-sonnet-5", 0, 0, 0, 1_000_000, 0, 0, None);
2147 assert!((cost_for_entry(&w5m) - 2.5).abs() < 1e-6);
2148 let w1h = entry_for_cost("claude-sonnet-5", 0, 0, 0, 1_000_000, 1_000_000, 0, None);
2149 assert!((cost_for_entry(&w1h) - 4.0).abs() < 1e-6);
2150
2151 let in46 = entry_for_cost("claude-sonnet-4-6", 1_000_000, 0, 0, 0, 0, 0, None);
2152 assert!((cost_for_entry(&in46) - 3.0).abs() < 1e-6);
2153
2154 let in5_1m = entry_for_cost("claude-sonnet-5[1m]", 1_000_000, 0, 0, 0, 0, 0, None);
2156 assert!((cost_for_entry(&in5_1m) - 2.0).abs() < 1e-6);
2157 }
2158
2159 #[test]
2160 fn sonnet_5_5_reads_cache_at_half_sonnet_5s_rate() {
2161 for id in ["claude-sonnet-5-5", "claude-sonnet-5-5[1m]"] {
2163 let p = pricing_for_model(id);
2164 assert_eq!((p.input, p.output), (2.0, 10.0), "{id}");
2165 assert_eq!((p.cache_write, p.cache_write_1h), (2.5, 4.0), "{id}");
2166 assert_eq!(p.cache_read, 0.1, "{id}");
2167 }
2168 assert_eq!(pricing_for_model("claude-sonnet-5").cache_read, 0.2);
2169 }
2170
2171 #[test]
2172 fn haiku_5_5_is_a_tenth_of_haiku_4_5() {
2173 for id in ["claude-haiku-5-5", "claude-haiku-5-5-20261007"] {
2175 let p = pricing_for_model(id);
2176 assert_eq!(p.input, 0.1, "{id}");
2177 assert_eq!(p.output, 0.5, "{id}");
2178 assert_eq!(p.cache_read, 0.01, "{id}");
2179 assert_eq!(p.cache_write, 0.125, "{id}");
2180 assert_eq!(p.cache_write_1h, 0.2, "{id}");
2181 }
2182 for id in ["claude-haiku-4-5", "claude-haiku-4-5-20251001", "haiku"] {
2184 let p = pricing_for_model(id);
2185 assert_eq!(p.input, 1.0, "{id}");
2186 assert_eq!(p.output, 5.0, "{id}");
2187 assert_eq!(p.cache_read, 0.1, "{id}");
2188 }
2189 }
2190
2191 #[test]
2192 fn haiku_5_5_long_prompts_bill_the_higher_tier() {
2193 let model = "claude-haiku-5-5";
2194 let at = entry_for_cost(model, 50_000, 0, 30_000, 20_000, 0, 0, None);
2196 let want_at = (50_000.0 * 0.1 + 30_000.0 * 0.01 + 20_000.0 * 0.125) / 1e6;
2197 assert!((cost_for_entry(&at) - want_at).abs() < 1e-9);
2198 let over = entry_for_cost(model, 50_000, 0, 30_000, 20_001, 0, 0, None);
2199 let want_over = (50_000.0 * 0.5 + 30_000.0 * 0.05 + 20_001.0 * 0.625) / 1e6;
2200 assert!((cost_for_entry(&over) - want_over).abs() < 1e-9);
2201
2202 let out = entry_for_cost(model, 100_001, 1_000_000, 0, 0, 0, 0, None);
2204 assert!((cost_for_entry(&out) - (100_001.0 * 0.5 + 2_500_000.0) / 1e6).abs() < 1e-9);
2205 let w1h = entry_for_cost(model, 0, 0, 0, 200_000, 200_000, 0, None);
2206 assert!((cost_for_entry(&w1h) - 0.2).abs() < 1e-9);
2207
2208 let h45 = entry_for_cost("claude-haiku-4-5", 200_000, 0, 0, 0, 0, 0, None);
2210 assert!((cost_for_entry(&h45) - 0.2).abs() < 1e-9);
2211 }
2212
2213 #[test]
2214 fn haiku_5_5_context_window_is_natively_1m() {
2215 assert_eq!(context_window_for_model("claude-haiku-5-5"), 1_000_000);
2216 assert_eq!(context_window_for_model("claude-haiku-4-5"), 200_000);
2217 assert_eq!(context_window_for_model("claude-haiku-4-5-20251001"), 200_000);
2218 }
2219
2220 #[test]
2221 fn unknown_models_keep_the_conservative_sonnet_rate() {
2222 for id in ["", "<unknown>", "sonnet", "claude-3-5-sonnet-20241022"] {
2225 let p = pricing_for_model(id);
2226 assert_eq!(p.input, 3.0, "{id} should price at the Sonnet default");
2227 }
2228 assert_eq!(pricing_for_model("fable").cache_read, 1.0);
2231 }
2232
2233 #[test]
2234 fn family_version_reads_minor_and_ignores_date_snapshots() {
2235 assert_eq!(family_version("claude-fable-5-1", "fable"), Some((5, 1)));
2236 assert_eq!(family_version("claude-fable-5", "fable"), Some((5, 0)));
2237 assert_eq!(family_version("claude-sonnet-5", "sonnet"), Some((5, 0)));
2238 assert_eq!(family_version("claude-sonnet-4-6", "sonnet"), Some((4, 6)));
2239 assert_eq!(family_version("claude-opus-5-5", "opus"), Some((5, 5)));
2240 assert_eq!(family_version("claude-opus-5-5[1m]", "opus"), Some((5, 5)));
2241 assert_eq!(
2242 family_version("claude-haiku-4-5-20251001", "haiku"),
2243 Some((4, 5))
2244 );
2245 assert_eq!(
2247 family_version("claude-sonnet-5-20260701", "sonnet"),
2248 Some((5, 0))
2249 );
2250 assert_eq!(family_version("fable", "fable"), None);
2251 assert_eq!(family_version("claude-opus-4-8", "sonnet"), None);
2252 assert_eq!(family_version("claude-3-5-sonnet-20241022", "sonnet"), None);
2255 }
2256
2257 #[test]
2258 fn parse_extracts_cache_split_speed_and_web_search() {
2259 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"}}}"#;
2260 let (_tmp, path) = write_jsonl(&[line]);
2261 let entries = parse_jsonl_entries(&path).unwrap().entries;
2262 assert_eq!(entries.len(), 1);
2263 let e = &entries[0];
2264 assert_eq!(e.cache_write_tokens, 40702, "flat total preserved (== 5m+1h)");
2265 assert_eq!(e.cache_write_1h_tokens, 40002);
2266 assert_eq!(e.web_search_requests, 2);
2267 assert_eq!(e.speed.as_deref(), Some("fast"));
2268 }
2269
2270 #[test]
2271 fn parse_cache_write_takes_max_of_flat_and_split() {
2272 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}}}}"#;
2274 let (_tmp, path) = write_jsonl(&[line]);
2275 let e = &parse_jsonl_entries(&path).unwrap().entries[0];
2276 assert_eq!(e.cache_write_tokens, 1400, "max(100, 500+900)");
2277 assert_eq!(e.cache_write_1h_tokens, 900);
2278 }
2279
2280 #[test]
2281 fn parse_missing_nested_cache_falls_back_to_flat() {
2282 let line = assistant_line("msg_3", "claude-opus-4-8", "2026-05-29T10:00:00.000Z", 5, 10, 0, 30000);
2284 let (_tmp, path) = write_jsonl(&[&line]);
2285 let e = &parse_jsonl_entries(&path).unwrap().entries[0];
2286 assert_eq!(e.cache_write_tokens, 30000);
2287 assert_eq!(e.cache_write_1h_tokens, 0);
2288 assert_eq!(e.web_search_requests, 0);
2289 assert_eq!(e.speed, None);
2290 }
2291
2292 #[test]
2293 fn reduce_session_prices_1h_cache_premium() {
2294 let e = entry_for_cost("claude-opus-4-8", 0, 0, 0, 1_000_000, 1_000_000, 0, None);
2296 let u = reduce_session(&[e], "", "sid").unwrap();
2297 assert!((u.estimated_cost_usd - 10.0).abs() < 1e-6);
2298 }
2299}