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
29pub fn pricing_for_model(model: &str) -> ModelPricing {
31 if model.contains("fable") {
32 ModelPricing {
35 input: 10.0,
36 output: 50.0,
37 cache_read: 1.0,
38 cache_write: 12.5,
39 cache_write_1h: 20.0,
40 }
41 } else if model.contains("opus") {
42 ModelPricing {
43 input: 5.0,
44 output: 25.0,
45 cache_read: 0.5,
46 cache_write: 6.25,
47 cache_write_1h: 10.0,
48 }
49 } else if model.contains("haiku") {
50 ModelPricing {
51 input: 1.0,
52 output: 5.0,
53 cache_read: 0.1,
54 cache_write: 1.25,
55 cache_write_1h: 2.0,
56 }
57 } else {
58 ModelPricing {
60 input: 3.0,
61 output: 15.0,
62 cache_read: 0.3,
63 cache_write: 3.75,
64 cache_write_1h: 6.0,
65 }
66 }
67}
68
69pub const WEB_SEARCH_COST_PER_REQUEST: f64 = 0.01;
71
72pub fn fast_multiplier_for_model(model: &str) -> f64 {
84 if model.contains("opus-4-8") {
85 2.0
86 } else if model.contains("opus-4-7") || model.contains("opus-4-6") {
87 6.0
88 } else {
89 1.0
90 }
91}
92
93pub fn cost_for_entry(entry: &UsageEntry) -> f64 {
101 let p = pricing_for_model(&entry.model);
102 let cw_1h = entry.cache_write_1h_tokens.min(entry.cache_write_tokens);
103 let cw_5m = entry.cache_write_tokens - cw_1h;
104 let token_cost = entry.input_tokens as f64 * p.input
105 + entry.output_tokens as f64 * p.output
106 + entry.cache_read_tokens as f64 * p.cache_read
107 + cw_5m as f64 * p.cache_write
108 + cw_1h as f64 * p.cache_write_1h;
109 let fast = if entry.speed.as_deref() == Some("fast") {
110 fast_multiplier_for_model(&entry.model)
111 } else {
112 1.0
113 };
114 (token_cost * fast) / 1_000_000.0
115 + entry.web_search_requests as f64 * WEB_SEARCH_COST_PER_REQUEST
116}
117
118pub fn context_window_for_model(model: &str) -> u64 {
119 if model.contains("[1m]") || model.contains("fable") {
121 1_000_000
122 } else {
123 200_000
124 }
125}
126
127#[derive(Debug, Clone, Serialize, Deserialize)]
143#[serde(rename_all = "camelCase")]
144pub struct UsageEntry {
145 pub session_id: String,
146 pub timestamp_ms: u64,
148 pub model: String,
149 pub input_tokens: u64,
150 pub output_tokens: u64,
151 pub cache_read_tokens: u64,
152 pub cache_write_tokens: u64,
157 #[serde(default)]
161 pub cache_write_1h_tokens: u64,
162 #[serde(default)]
165 pub web_search_requests: u64,
166 #[serde(default)]
170 pub speed: Option<String>,
171 pub tool_uses: Vec<String>,
175 pub text_chars: u32,
178 pub activity: Activity,
181 pub entrypoint: Option<String>,
188}
189
190#[derive(Debug, Clone, Serialize, Deserialize)]
193#[serde(rename_all = "camelCase")]
194pub struct HookExecution {
195 pub session_id: String,
196 pub timestamp_ms: u64,
197 pub hook_event: String,
199 pub hook_name: String,
201 pub command: String,
203 pub exit_code: i32,
204 pub duration_ms: u64,
205 pub tool_use_id: Option<String>,
207}
208
209#[derive(Default)]
211pub struct ParsedJsonl {
212 pub entries: Vec<UsageEntry>,
213 pub hooks: Vec<HookExecution>,
214}
215
216#[derive(Debug, Clone, Serialize, Default)]
218#[serde(rename_all = "camelCase")]
219pub struct SessionUsage {
220 pub model: String,
221 pub input_tokens: u64,
222 pub output_tokens: u64,
223 pub cache_read_tokens: u64,
224 pub cache_write_tokens: u64,
225 pub total_tokens: u64,
226 pub estimated_cost_usd: f64,
227 pub context_window_size: u64,
228 pub context_window_used_pct: f64,
229 pub current_context_tokens: u64,
231 pub total_context_sent: u64,
233 pub cache_hit_pct: f64,
235 pub message_count: u64,
236 pub session_id: String,
237}
238
239#[derive(Deserialize)]
241#[serde(rename_all = "camelCase")]
242struct JsonlRaw {
243 #[serde(rename = "type")]
244 entry_type: Option<String>,
245 timestamp: Option<String>,
246 message: Option<MessageData>,
247 attachment: Option<AttachmentData>,
248 subtype: Option<String>,
250 #[allow(dead_code)]
251 hook_count: Option<usize>,
252 hook_infos: Option<Vec<HookInfoEntry>>,
253 hook_errors: Option<Vec<serde_json::Value>>,
254 prevented_continuation: Option<bool>,
255 #[serde(rename = "toolUseID")]
256 tool_use_id: Option<String>,
257 entrypoint: Option<String>,
260}
261
262#[derive(Deserialize)]
263struct MessageData {
264 id: Option<String>,
265 model: Option<String>,
266 usage: Option<UsageData>,
267 #[serde(default)]
272 content: Option<serde_json::Value>,
273}
274
275#[derive(Deserialize, Default)]
276struct UsageData {
277 input_tokens: Option<u64>,
278 output_tokens: Option<u64>,
279 cache_read_input_tokens: Option<u64>,
280 cache_creation_input_tokens: Option<u64>,
283 cache_creation: Option<CacheCreation>,
285 server_tool_use: Option<ServerToolUse>,
287 speed: Option<String>,
289}
290
291#[derive(Deserialize, Default)]
293struct CacheCreation {
294 ephemeral_5m_input_tokens: Option<u64>,
295 ephemeral_1h_input_tokens: Option<u64>,
296}
297
298#[derive(Deserialize, Default)]
301struct ServerToolUse {
302 web_search_requests: Option<u64>,
303}
304
305#[derive(Deserialize)]
307#[serde(rename_all = "camelCase")]
308struct HookInfoEntry {
309 command: Option<String>,
310 duration_ms: Option<u64>,
311}
312
313#[derive(Deserialize)]
315#[serde(rename_all = "camelCase")]
316struct AttachmentData {
317 #[serde(rename = "type")]
318 attachment_type: Option<String>,
319 hook_name: Option<String>,
320 hook_event: Option<String>,
321 command: Option<String>,
322 exit_code: Option<i32>,
323 duration_ms: Option<u64>,
324 #[serde(rename = "toolUseID")]
325 tool_use_id: Option<String>,
326}
327
328#[derive(Deserialize)]
330#[serde(rename_all = "camelCase")]
331struct ActiveSession {
332 session_id: String,
333 cwd: String,
334 started_at: u64,
335}
336
337use super::MAX_JSONL_BYTES;
344
345pub fn encode_project_path(project_path: &str) -> String {
355 let normalized = project_path.replace('/', "\\");
356 let mut result = String::new();
357 let mut chars = normalized.chars().peekable();
358
359 while let Some(ch) = chars.next() {
360 if ch == ':' {
361 if chars.peek() == Some(&'\\') {
362 chars.next();
363 result.push_str("--");
364 } else {
365 result.push('-');
366 }
367 } else if ch == '\\' {
368 result.push('-');
369 } else {
370 result.push(ch);
371 }
372 }
373
374 while result.ends_with('-') {
375 result.pop();
376 }
377
378 result
379}
380
381struct AssistantContent {
383 tool_uses: Vec<String>,
384 bash_cmds: Vec<String>,
385 text_chars: u32,
386}
387
388fn extract_assistant_content(content: Option<&serde_json::Value>) -> AssistantContent {
392 let mut out = AssistantContent {
393 tool_uses: Vec::new(),
394 bash_cmds: Vec::new(),
395 text_chars: 0,
396 };
397 let arr = match content.and_then(|v| v.as_array()) {
398 Some(a) => a,
399 None => return out,
400 };
401
402 for block in arr {
403 let block_type = match block.get("type").and_then(|v| v.as_str()) {
404 Some(t) => t,
405 None => continue,
406 };
407 match block_type {
408 "tool_use" => {
409 if let Some(name) = block.get("name").and_then(|v| v.as_str()) {
410 if matches!(name, "Bash" | "BashTool" | "PowerShellTool") {
413 if let Some(cmd) = block
414 .get("input")
415 .and_then(|i| i.get("command"))
416 .and_then(|c| c.as_str())
417 {
418 out.bash_cmds.push(cmd.to_string());
419 }
420 }
421 out.tool_uses.push(name.to_string());
422 }
423 }
424 "text" => {
425 if let Some(text) = block.get("text").and_then(|v| v.as_str()) {
426 out.text_chars = out
429 .text_chars
430 .saturating_add(text.chars().count() as u32);
431 }
432 }
433 _ => {}
434 }
435 }
436
437 out
438}
439
440fn sanitize_hook_command_label(cmd: &str) -> String {
459 const MAX_LEN: usize = 64;
460 let first_line = cmd.lines().next().unwrap_or("").trim();
461 let collapsed: String = first_line.split_whitespace().collect::<Vec<_>>().join(" ");
462 if collapsed.chars().count() <= MAX_LEN {
463 return collapsed;
464 }
465 let truncated: String = collapsed.chars().take(MAX_LEN).collect();
466 format!("{truncated}…")
467}
468
469fn snake_to_pascal(s: &str) -> String {
471 s.split('_')
472 .filter(|w| !w.is_empty())
473 .map(|w| {
474 let mut c = w.chars();
475 match c.next() {
476 Some(first) => {
477 let mut out = first.to_uppercase().to_string();
478 out.extend(c);
479 out
480 }
481 None => String::new(),
482 }
483 })
484 .collect()
485}
486
487fn extract_user_text(content: &serde_json::Value) -> Option<String> {
490 match content {
491 serde_json::Value::String(s) => {
492 if s.is_empty() {
493 None
494 } else {
495 Some(s.clone())
496 }
497 }
498 serde_json::Value::Array(arr) => {
499 let mut parts: Vec<&str> = Vec::new();
500 for item in arr {
501 let t = item.get("type").and_then(|v| v.as_str()).unwrap_or("");
502 if t == "text" {
503 if let Some(text) = item.get("text").and_then(|v| v.as_str()) {
504 if !text.is_empty() {
505 parts.push(text);
506 }
507 }
508 }
509 }
510 if parts.is_empty() {
511 None
512 } else {
513 Some(parts.join(" "))
514 }
515 }
516 _ => None,
517 }
518}
519
520struct EntryAccumulator {
523 out_idx: usize,
524 bash_cmds: Vec<String>,
525 user_text_at_first: Option<String>,
526}
527
528pub fn parse_jsonl_entries(path: &Path) -> std::io::Result<ParsedJsonl> {
552 let meta = fs::metadata(path)?;
553 if meta.len() > MAX_JSONL_BYTES {
554 tracing::warn!(
555 path = %path.display(),
556 size = meta.len(),
557 "skipping JSONL larger than {}MB",
558 MAX_JSONL_BYTES / 1024 / 1024
559 );
560 return Ok(ParsedJsonl::default());
561 }
562
563 let file = fs::File::open(path)?;
564 let reader = BufReader::new(file);
565
566 let session_id = path
567 .file_stem()
568 .and_then(|s| s.to_str())
569 .unwrap_or("")
570 .to_string();
571
572 let mut accums: HashMap<String, EntryAccumulator> = HashMap::new();
573 let mut out: Vec<UsageEntry> = Vec::new();
574 let mut hooks: Vec<HookExecution> = Vec::new();
575 let mut last_user_text: Option<String> = None;
576
577 for line in reader.lines() {
578 let line = match line {
579 Ok(l) => l,
580 Err(_) => continue,
581 };
582 let line = line.trim();
583 if line.is_empty() {
584 continue;
585 }
586
587 let raw: JsonlRaw = match serde_json::from_str(line) {
588 Ok(r) => r,
589 Err(_) => continue,
590 };
591
592 let entry_type = raw.entry_type.as_deref();
593
594 if entry_type == Some("user") {
597 if let Some(msg) = raw.message.as_ref() {
598 if let Some(content) = msg.content.as_ref() {
599 if let Some(text) = extract_user_text(content) {
600 last_user_text = Some(normalize_user_text(&text));
601 }
602 }
603 }
604 continue;
605 }
606
607 if entry_type == Some("attachment") {
609 if let Some(att) = raw.attachment {
610 if att.attachment_type.as_deref() == Some("hook_success") {
611 let timestamp_ms = raw
612 .timestamp
613 .as_deref()
614 .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
615 .map(|dt| dt.timestamp_millis().max(0) as u64)
616 .unwrap_or(0);
617 hooks.push(HookExecution {
618 session_id: session_id.clone(),
619 timestamp_ms,
620 hook_event: att.hook_event.unwrap_or_default(),
621 hook_name: att.hook_name.unwrap_or_default(),
622 command: att.command.unwrap_or_default(),
623 exit_code: att.exit_code.unwrap_or(0),
624 duration_ms: att.duration_ms.unwrap_or(0),
625 tool_use_id: att.tool_use_id,
626 });
627 }
628 }
629 continue;
630 }
631
632 if entry_type == Some("system") {
635 if let Some(subtype) = raw.subtype.as_deref() {
636 if let Some(event_snake) = subtype.strip_suffix("_hook_summary") {
637 let hook_event = snake_to_pascal(event_snake);
638 let timestamp_ms = raw
639 .timestamp
640 .as_deref()
641 .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
642 .map(|dt| dt.timestamp_millis().max(0) as u64)
643 .unwrap_or(0);
644 let had_errors = raw
645 .hook_errors
646 .as_ref()
647 .is_some_and(|e| !e.is_empty());
648 let prevented = raw.prevented_continuation.unwrap_or(false);
649 if let Some(infos) = raw.hook_infos {
650 for info in infos {
651 let cmd = info.command.unwrap_or_default();
652 let hook_name =
653 format!("{}:{}", hook_event, sanitize_hook_command_label(&cmd));
654 hooks.push(HookExecution {
655 session_id: session_id.clone(),
656 timestamp_ms,
657 hook_event: hook_event.clone(),
658 hook_name,
659 command: cmd,
660 exit_code: if had_errors || prevented { 1 } else { 0 },
661 duration_ms: info.duration_ms.unwrap_or(0),
662 tool_use_id: raw.tool_use_id.clone(),
663 });
664 }
665 }
666 }
667 }
668 continue;
669 }
670
671 if entry_type != Some("assistant") {
672 continue;
673 }
674
675 let msg = match raw.message {
676 Some(m) => m,
677 None => continue,
678 };
679 let mid = match msg.id {
680 Some(id) => id,
681 None => continue,
682 };
683
684 let mut content_parsed = extract_assistant_content(msg.content.as_ref());
685
686 if let Some(acc) = accums.get_mut(&mid) {
689 let entry = &mut out[acc.out_idx];
690 entry
691 .tool_uses
692 .append(&mut content_parsed.tool_uses);
693 entry.text_chars = entry
694 .text_chars
695 .saturating_add(content_parsed.text_chars);
696 acc.bash_cmds.append(&mut content_parsed.bash_cmds);
697 continue;
698 }
699
700 let model = msg.model.unwrap_or_default();
706 if model == "<synthetic>" {
707 continue;
708 }
709
710 let usage = msg.usage.unwrap_or_default();
711 let timestamp_ms = raw
712 .timestamp
713 .as_deref()
714 .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
715 .map(|dt| dt.timestamp_millis().max(0) as u64)
716 .unwrap_or(0);
717
718 let cw_flat = usage.cache_creation_input_tokens.unwrap_or(0);
722 let (eph_5m, eph_1h) = usage
723 .cache_creation
724 .as_ref()
725 .map(|c| {
726 (
727 c.ephemeral_5m_input_tokens.unwrap_or(0),
728 c.ephemeral_1h_input_tokens.unwrap_or(0),
729 )
730 })
731 .unwrap_or((0, 0));
732 let cache_write_tokens = cw_flat.max(eph_5m + eph_1h);
733 let cache_write_1h_tokens = eph_1h.min(cache_write_tokens);
734 let web_search_requests = usage
735 .server_tool_use
736 .as_ref()
737 .map(|s| s.web_search_requests.unwrap_or(0))
738 .unwrap_or(0);
739 let speed = usage.speed.clone();
740
741 accums.insert(
745 mid.clone(),
746 EntryAccumulator {
747 out_idx: out.len(),
748 bash_cmds: std::mem::take(&mut content_parsed.bash_cmds),
749 user_text_at_first: last_user_text.clone(),
750 },
751 );
752 out.push(UsageEntry {
753 session_id: session_id.clone(),
754 timestamp_ms,
755 model,
756 input_tokens: usage.input_tokens.unwrap_or(0),
757 output_tokens: usage.output_tokens.unwrap_or(0),
758 cache_read_tokens: usage.cache_read_input_tokens.unwrap_or(0),
759 cache_write_tokens,
760 cache_write_1h_tokens,
761 web_search_requests,
762 speed,
763 tool_uses: content_parsed.tool_uses,
764 text_chars: content_parsed.text_chars,
765 activity: Activity::General,
766 entrypoint: raw.entrypoint.clone(),
767 });
768 }
769
770 for acc in accums.values() {
773 let entry = &mut out[acc.out_idx];
774 let tool_refs: Vec<&str> = entry.tool_uses.iter().map(|s| s.as_str()).collect();
775 let bash_refs: Vec<&str> = acc.bash_cmds.iter().map(|s| s.as_str()).collect();
776 entry.activity = classify(
777 &tool_refs,
778 &bash_refs,
779 acc.user_text_at_first.as_deref(),
780 entry.text_chars,
781 );
782 }
783
784 Ok(ParsedJsonl {
785 entries: out,
786 hooks,
787 })
788}
789
790pub fn find_current_session_jsonl(
794 config_dir: &str,
795 project_path: &str,
796 spawned_at: SystemTime,
797) -> Option<PathBuf> {
798 let config = Path::new(config_dir);
799 let encoded = encode_project_path(project_path);
800 let projects_dir = config.join("projects").join(&encoded);
801
802 if !projects_dir.exists() {
803 return None;
804 }
805
806 let sessions_dir = config.join("sessions");
809 if sessions_dir.exists() {
810 if let Some(session_id) = find_active_session_id(&sessions_dir, project_path, spawned_at) {
811 let jsonl_path = projects_dir.join(format!("{}.jsonl", session_id));
812 if jsonl_path.exists() {
813 return Some(jsonl_path);
814 }
815 }
816 }
817
818 let mut candidates: Vec<(PathBuf, SystemTime)> = Vec::new();
823
824 if let Ok(entries) = fs::read_dir(&projects_dir) {
825 for entry in entries.flatten() {
826 let path = entry.path();
827 if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
828 continue;
829 }
830 if let Ok(meta) = path.metadata() {
831 let created = meta.created().unwrap_or(SystemTime::UNIX_EPOCH);
832 if created >= spawned_at {
833 candidates.push((path, created));
834 }
835 }
836 }
837 }
838
839 candidates.sort_by_key(|a| a.1);
841 candidates.into_iter().next().map(|(p, _)| p)
842}
843
844fn find_active_session_id(
847 sessions_dir: &Path,
848 project_path: &str,
849 spawned_at: SystemTime,
850) -> Option<String> {
851 let spawned_ms = spawned_at
852 .duration_since(SystemTime::UNIX_EPOCH)
853 .ok()?
854 .as_millis() as u64;
855
856 let threshold_ms = spawned_ms.saturating_sub(10_000);
858
859 let normalized_project = normalize_path(project_path);
860
861 let mut best: Option<(String, u64)> = None;
862
863 if let Ok(entries) = fs::read_dir(sessions_dir) {
864 for entry in entries.flatten() {
865 let path = entry.path();
866 if path.extension().and_then(|e| e.to_str()) != Some("json") {
867 continue;
868 }
869 if let Ok(content) = fs::read_to_string(&path) {
870 if let Ok(session) = serde_json::from_str::<ActiveSession>(&content) {
871 let session_cwd = normalize_path(&session.cwd);
872 if session_cwd == normalized_project && session.started_at >= threshold_ms {
873 if best.as_ref().is_none_or(|(_, t)| session.started_at < *t) {
879 best = Some((session.session_id, session.started_at));
880 }
881 }
882 }
883 }
884 }
885 }
886
887 best.map(|(id, _)| id)
888}
889
890fn normalize_path(p: &str) -> String {
892 p.replace('\\', "/")
893 .trim_end_matches('/')
894 .to_lowercase()
895}
896
897pub fn reduce_session(
906 entries: &[UsageEntry],
907 launched_model: &str,
908 session_id_hint: &str,
909) -> Option<SessionUsage> {
910 if entries.is_empty() {
911 return None;
912 }
913
914 let mut input_tokens = 0u64;
915 let mut output_tokens = 0u64;
916 let mut cache_read = 0u64;
917 let mut cache_write = 0u64;
918 let mut model_counts: HashMap<&str, u64> = HashMap::new();
919
920 for e in entries {
921 input_tokens += e.input_tokens;
922 output_tokens += e.output_tokens;
923 cache_read += e.cache_read_tokens;
924 cache_write += e.cache_write_tokens;
925 if !e.model.is_empty() {
926 *model_counts.entry(e.model.as_str()).or_insert(0) += 1;
927 }
928 }
929
930 let primary_model = model_counts
931 .into_iter()
932 .max_by_key(|(_, c)| *c)
933 .map(|(m, _)| m.to_string())
934 .unwrap_or_else(|| {
935 entries
936 .last()
937 .map(|e| e.model.clone())
938 .unwrap_or_default()
939 });
940
941 let estimated_cost: f64 = entries.iter().map(cost_for_entry).sum();
945
946 let context_model = if launched_model.is_empty() {
947 &primary_model
948 } else {
949 launched_model
950 };
951 let context_window = context_window_for_model(context_model);
952
953 let last = entries.last().unwrap();
954 let last_context_used =
955 last.input_tokens + last.cache_read_tokens + last.cache_write_tokens;
956 let context_pct = if context_window > 0 {
957 (last_context_used as f64 / context_window as f64 * 100.0).min(100.0)
958 } else {
959 0.0
960 };
961
962 let total = input_tokens + output_tokens + cache_read + cache_write;
963 let total_context_sent = input_tokens + cache_read + cache_write;
964
965 let cache_denom = input_tokens + cache_read + cache_write;
970 let cache_hit_pct = if cache_denom > 0 {
971 (cache_read as f64 / cache_denom as f64) * 100.0
972 } else {
973 0.0
974 };
975
976 let session_id = entries
977 .first()
978 .map(|e| e.session_id.clone())
979 .filter(|s| !s.is_empty())
980 .unwrap_or_else(|| session_id_hint.to_string());
981
982 Some(SessionUsage {
983 model: primary_model,
984 input_tokens,
985 output_tokens,
986 cache_read_tokens: cache_read,
987 cache_write_tokens: cache_write,
988 total_tokens: total,
989 estimated_cost_usd: estimated_cost,
990 context_window_size: context_window,
991 context_window_used_pct: context_pct,
992 current_context_tokens: last_context_used,
993 total_context_sent,
994 cache_hit_pct,
995 message_count: entries.len() as u64,
996 session_id,
997 })
998}
999
1000#[cfg(test)]
1001mod tests {
1002 use super::*;
1003 use std::io::Write;
1004 use tempfile::TempDir;
1005
1006 #[test]
1007 fn test_encode_project_path_drive_root() {
1008 assert_eq!(encode_project_path("S:\\Loom"), "S--Loom");
1009 }
1010
1011 #[test]
1012 fn test_encode_project_path_deep() {
1013 assert_eq!(
1014 encode_project_path("C:\\Users\\Seven\\Desktop\\SF\\project"),
1015 "C--Users-Seven-Desktop-SF-project"
1016 );
1017 }
1018
1019 #[test]
1020 fn test_encode_project_path_forward_slashes() {
1021 assert_eq!(encode_project_path("S:/Loom"), "S--Loom");
1022 }
1023
1024 #[test]
1025 fn test_encode_project_path_trailing_slash() {
1026 assert_eq!(encode_project_path("S:\\Loom\\"), "S--Loom");
1027 }
1028
1029 #[test]
1030 fn test_encode_project_path_macos() {
1031 assert_eq!(
1032 encode_project_path("/Users/seven/Loom"),
1033 "-Users-seven-Loom"
1034 );
1035 }
1036
1037 #[test]
1038 fn test_encode_project_path_linux() {
1039 assert_eq!(
1040 encode_project_path("/home/alice/projects/foo"),
1041 "-home-alice-projects-foo"
1042 );
1043 }
1044
1045 #[test]
1046 fn test_encode_project_path_unix_trailing_slash() {
1047 assert_eq!(encode_project_path("/Users/seven/Loom/"), "-Users-seven-Loom");
1048 }
1049
1050 #[test]
1051 fn test_normalize_path() {
1052 assert_eq!(normalize_path("S:\\Loom\\"), "s:/loom");
1053 assert_eq!(normalize_path("S:/Loom"), "s:/loom");
1054 assert_eq!(normalize_path("C:\\Users\\Seven\\"), "c:/users/seven");
1055 }
1056
1057 fn write_jsonl(lines: &[&str]) -> (TempDir, PathBuf) {
1058 let tmp = TempDir::new().unwrap();
1059 let path = tmp.path().join("abc123.jsonl");
1060 let mut f = fs::File::create(&path).unwrap();
1061 for l in lines {
1062 writeln!(f, "{}", l).unwrap();
1063 }
1064 (tmp, path)
1065 }
1066
1067 fn assistant_line(id: &str, model: &str, ts: &str, inp: u64, out: u64, cr: u64, cw: u64) -> String {
1068 format!(
1069 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}}}}}}}"#
1070 )
1071 }
1072
1073 #[test]
1074 fn parse_deduplicates_repeated_message_ids() {
1075 let a1 = assistant_line("msg_01", "claude-opus-4-6", "2026-04-14T08:27:38.895Z", 15, 1463, 16397, 25661);
1078 let a1_dup = a1.clone();
1079 let a2 = assistant_line("msg_02", "claude-opus-4-6", "2026-04-14T08:34:37.200Z", 10, 401, 42058, 1668);
1080 let a2_dup = a2.clone();
1081 let a2_dup2 = a2.clone();
1082 let (_tmp, path) = write_jsonl(&[&a1, &a1_dup, &a2, &a2_dup, &a2_dup2]);
1083
1084 let entries = parse_jsonl_entries(&path).unwrap().entries;
1085 assert_eq!(entries.len(), 2, "should dedup by message.id");
1086 assert_eq!(entries[0].input_tokens, 15);
1087 assert_eq!(entries[0].output_tokens, 1463);
1088 assert_eq!(entries[1].input_tokens, 10);
1089 }
1090
1091 #[test]
1092 fn parse_captures_entrypoint_from_assistant_row() {
1093 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}}}"#;
1097 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}}}"#;
1098 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}}}"#;
1099 let (_tmp, path) = write_jsonl(&[headless, interactive, legacy]);
1100
1101 let entries = parse_jsonl_entries(&path).unwrap().entries;
1102 assert_eq!(entries.len(), 3);
1103 assert_eq!(entries[0].entrypoint.as_deref(), Some("sdk-cli"));
1104 assert_eq!(entries[1].entrypoint.as_deref(), Some("cli"));
1105 assert_eq!(entries[2].entrypoint, None);
1106 }
1107
1108 #[test]
1109 fn parse_skips_non_assistant_entries() {
1110 let lines = [
1111 r#"{"type":"user","message":{"role":"user","content":"hi"}}"#.to_string(),
1112 r#"{"type":"permission-mode"}"#.to_string(),
1113 r#"{"type":"file-history-snapshot"}"#.to_string(),
1114 r#"{"type":"attachment","timestamp":"2026-04-14T08:27:26.236Z"}"#.to_string(),
1115 assistant_line("msg_01", "claude-sonnet-4-6", "2026-04-14T08:28:00.000Z", 100, 200, 0, 0),
1116 ];
1117 let line_refs: Vec<&str> = lines.iter().map(|s| s.as_str()).collect();
1118 let (_tmp, path) = write_jsonl(&line_refs);
1119
1120 let entries = parse_jsonl_entries(&path).unwrap().entries;
1121 assert_eq!(entries.len(), 1);
1122 assert_eq!(entries[0].model, "claude-sonnet-4-6");
1123 }
1124
1125 #[test]
1126 fn parse_tolerates_malformed_lines_and_empty_lines() {
1127 let good = assistant_line("msg_01", "claude-opus-4-6", "2026-04-14T08:27:38.895Z", 5, 10, 0, 0);
1128 let lines = vec![
1129 "",
1130 "not json at all",
1131 r#"{"type":"assistant","message":null}"#,
1132 r#"{"type":"assistant","message":{"id":null}}"#,
1133 &good,
1134 " ",
1135 r#"{"broken":"trailing"#,
1136 ];
1137 let (_tmp, path) = write_jsonl(&lines);
1138
1139 let entries = parse_jsonl_entries(&path).unwrap().entries;
1140 assert_eq!(entries.len(), 1);
1141 }
1142
1143 #[test]
1144 fn parse_extracts_session_id_from_file_stem() {
1145 let line = assistant_line("msg_01", "claude-opus-4-6", "2026-04-14T08:27:38.895Z", 1, 1, 0, 0);
1146 let (_tmp, path) = write_jsonl(&[&line]);
1147 let entries = parse_jsonl_entries(&path).unwrap().entries;
1148 assert_eq!(entries[0].session_id, "abc123");
1149 }
1150
1151 #[test]
1152 fn parse_parses_iso_timestamps() {
1153 let line = assistant_line("msg_01", "claude-opus-4-6", "2026-04-14T08:27:38.895Z", 1, 1, 0, 0);
1154 let (_tmp, path) = write_jsonl(&[&line]);
1155 let entries = parse_jsonl_entries(&path).unwrap().entries;
1156 assert_eq!(entries[0].timestamp_ms, 1_776_155_258_895);
1158 }
1159
1160 #[test]
1161 fn parse_skips_synthetic_model_rows() {
1162 let real = assistant_line("msg_real", "claude-opus-4-6", "2026-04-11T00:27:38.895Z", 15, 100, 0, 0);
1165 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}}}"#;
1166 let (_tmp, path) = write_jsonl(&[&real, synthetic]);
1167 let entries = parse_jsonl_entries(&path).unwrap().entries;
1168 assert_eq!(entries.len(), 1);
1169 assert_eq!(entries[0].model, "claude-opus-4-6");
1170 }
1171
1172 #[test]
1173 fn parse_handles_missing_usage_fields() {
1174 let line = r#"{"type":"assistant","timestamp":"2026-04-14T08:27:38.895Z","message":{"id":"msg_01","model":"claude-opus-4-6"}}"#;
1175 let (_tmp, path) = write_jsonl(&[line]);
1176 let entries = parse_jsonl_entries(&path).unwrap().entries;
1177 assert_eq!(entries.len(), 1);
1178 assert_eq!(entries[0].input_tokens, 0);
1179 assert_eq!(entries[0].cache_read_tokens, 0);
1180 }
1181
1182 #[test]
1183 fn reduce_computes_primary_model_by_mode() {
1184 let e = |m: &str| UsageEntry {
1185 session_id: "s".into(),
1186 timestamp_ms: 0,
1187 model: m.into(),
1188 input_tokens: 1,
1189 output_tokens: 1,
1190 cache_read_tokens: 0,
1191 cache_write_tokens: 0,
1192 cache_write_1h_tokens: 0,
1193 web_search_requests: 0,
1194 speed: None,
1195 tool_uses: Vec::new(),
1196 text_chars: 0,
1197 activity: Activity::General,
1198 entrypoint: None,
1199 };
1200 let entries = vec![
1201 e("claude-opus-4-6"),
1202 e("claude-opus-4-6"),
1203 e("claude-opus-4-6"),
1204 e("claude-sonnet-4-6"),
1205 ];
1206 let u = reduce_session(&entries, "", "sid").unwrap();
1207 assert_eq!(u.model, "claude-opus-4-6");
1208 assert_eq!(u.message_count, 4);
1209 }
1210
1211 #[test]
1212 fn reduce_uses_launched_model_for_context_window() {
1213 let entries = vec![UsageEntry {
1214 session_id: "s".into(),
1215 timestamp_ms: 0,
1216 model: "claude-opus-4-6".into(),
1218 input_tokens: 1000,
1219 output_tokens: 0,
1220 cache_read_tokens: 0,
1221 cache_write_tokens: 0,
1222 cache_write_1h_tokens: 0,
1223 web_search_requests: 0,
1224 speed: None,
1225 tool_uses: Vec::new(),
1226 text_chars: 0,
1227 activity: Activity::General,
1228 entrypoint: None,
1229 }];
1230 let with_1m = reduce_session(&entries, "claude-opus-4-6[1m]", "sid").unwrap();
1231 let without = reduce_session(&entries, "", "sid").unwrap();
1232 assert_eq!(with_1m.context_window_size, 1_000_000);
1233 assert_eq!(without.context_window_size, 200_000);
1234 }
1235
1236 #[test]
1237 fn reduce_context_pct_uses_last_entry_only() {
1238 let mk = |input: u64, cr: u64, cw: u64| UsageEntry {
1240 session_id: "s".into(),
1241 timestamp_ms: 0,
1242 model: "claude-opus-4-6".into(),
1243 input_tokens: input,
1244 output_tokens: 0,
1245 cache_read_tokens: cr,
1246 cache_write_tokens: cw,
1247 cache_write_1h_tokens: 0,
1248 web_search_requests: 0,
1249 speed: None,
1250 tool_uses: Vec::new(),
1251 text_chars: 0,
1252 activity: Activity::General,
1253 entrypoint: None,
1254 };
1255 let entries = vec![
1256 mk(1000, 0, 0),
1257 mk(500, 0, 0),
1258 mk(5, 160_000, 0), ];
1260 let u = reduce_session(&entries, "", "sid").unwrap();
1261 assert_eq!(u.current_context_tokens, 160_005);
1262 assert!((u.context_window_used_pct - 80.0025).abs() < 0.01);
1263 }
1264
1265 #[test]
1266 fn reduce_cache_hit_pct() {
1267 let e = |input: u64, cr: u64, cw: u64| UsageEntry {
1268 session_id: "s".into(),
1269 timestamp_ms: 0,
1270 model: "claude-opus-4-6".into(),
1271 input_tokens: input,
1272 output_tokens: 1,
1273 cache_read_tokens: cr,
1274 cache_write_tokens: cw,
1275 cache_write_1h_tokens: 0,
1276 web_search_requests: 0,
1277 speed: None,
1278 tool_uses: Vec::new(),
1279 text_chars: 0,
1280 activity: Activity::General,
1281 entrypoint: None,
1282 };
1283 let u = reduce_session(&[e(3, 97, 0)], "", "sid").unwrap();
1285 assert!((u.cache_hit_pct - 97.0).abs() < 0.01);
1286
1287 let u = reduce_session(&[e(10, 80, 10)], "", "sid").unwrap();
1289 assert!((u.cache_hit_pct - 80.0).abs() < 0.01);
1290
1291 let u = reduce_session(&[e(0, 0, 0)], "", "sid").unwrap();
1293 assert_eq!(u.cache_hit_pct, 0.0);
1294 }
1295
1296 #[test]
1297 fn reduce_total_context_sent_is_cumulative() {
1298 let mk = |input: u64, cr: u64, cw: u64| UsageEntry {
1299 session_id: "s".into(),
1300 timestamp_ms: 0,
1301 model: "claude-opus-4-6".into(),
1302 input_tokens: input,
1303 output_tokens: 0,
1304 cache_read_tokens: cr,
1305 cache_write_tokens: cw,
1306 cache_write_1h_tokens: 0,
1307 web_search_requests: 0,
1308 speed: None,
1309 tool_uses: Vec::new(),
1310 text_chars: 0,
1311 activity: Activity::General,
1312 entrypoint: None,
1313 };
1314 let u = reduce_session(
1315 &[mk(15, 16_397, 25_661), mk(10, 42_058, 1_668)],
1316 "",
1317 "sid",
1318 )
1319 .unwrap();
1320 assert_eq!(u.total_context_sent, 15 + 16_397 + 25_661 + 10 + 42_058 + 1_668);
1321 }
1322
1323 #[test]
1324 fn reduce_cost_matches_opus_pricing() {
1325 let entries = vec![UsageEntry {
1327 session_id: "s".into(),
1328 timestamp_ms: 0,
1329 model: "claude-opus-4-6".into(),
1330 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,
1335 web_search_requests: 0,
1336 speed: None,
1337 tool_uses: Vec::new(),
1338 text_chars: 0,
1339 activity: Activity::General,
1340 entrypoint: None,
1341 }];
1342 let u = reduce_session(&entries, "", "sid").unwrap();
1343 assert!((u.estimated_cost_usd - 36.75).abs() < 0.001);
1344 }
1345
1346 #[test]
1347 fn reduce_empty_returns_none() {
1348 assert!(reduce_session(&[], "", "sid").is_none());
1349 }
1350
1351 fn assistant_with_content(id: &str, ts: &str, content_json: &str) -> String {
1354 format!(
1355 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}}}}}"#
1356 )
1357 }
1358
1359 #[test]
1360 fn parse_extracts_tool_use_names() {
1361 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"}}]"#;
1362 let line = assistant_with_content("msg_1", "2026-04-14T08:27:38.895Z", content);
1363 let (_tmp, path) = write_jsonl(&[&line]);
1364 let entries = parse_jsonl_entries(&path).unwrap().entries;
1365 assert_eq!(entries.len(), 1);
1366 assert_eq!(entries[0].tool_uses, vec!["Edit".to_string(), "Read".to_string()]);
1367 }
1368
1369 #[test]
1370 fn parse_captures_text_char_count() {
1371 let content = r#"[{"type":"text","text":"Hello world"},{"type":"text","text":"!"}]"#;
1372 let line = assistant_with_content("msg_1", "2026-04-14T08:27:38.895Z", content);
1373 let (_tmp, path) = write_jsonl(&[&line]);
1374 let entries = parse_jsonl_entries(&path).unwrap().entries;
1375 assert_eq!(entries[0].text_chars, 12); }
1377
1378 #[test]
1379 fn parse_handles_missing_content_gracefully() {
1380 let line = assistant_line("msg_1", "claude-opus-4-6", "2026-04-14T08:27:38.895Z", 5, 10, 0, 0);
1383 let (_tmp, path) = write_jsonl(&[&line]);
1384 let entries = parse_jsonl_entries(&path).unwrap().entries;
1385 assert_eq!(entries.len(), 1);
1386 assert!(entries[0].tool_uses.is_empty());
1387 assert_eq!(entries[0].text_chars, 0);
1388 assert_eq!(entries[0].activity, Activity::General);
1389 }
1390
1391 #[test]
1392 fn parse_classifies_edit_plus_debug_as_debugging() {
1393 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();
1395 let content = r#"[{"type":"tool_use","id":"tu_1","name":"Edit","input":{"file_path":"login.rs"}}]"#;
1396 let asst_line = assistant_with_content("msg_1", "2026-04-14T08:27:38.895Z", content);
1397 let (_tmp, path) = write_jsonl(&[&user_line, &asst_line]);
1398 let entries = parse_jsonl_entries(&path).unwrap().entries;
1399 assert_eq!(entries.len(), 1);
1400 assert_eq!(entries[0].activity, Activity::Debugging);
1401 }
1402
1403 #[test]
1404 fn parse_classifies_bash_pytest_as_testing() {
1405 let user_line = r#"{"type":"user","timestamp":"2026-04-14T08:27:30.000Z","message":{"role":"user","content":"run the tests"}}"#.to_string();
1406 let content = r#"[{"type":"tool_use","id":"tu_1","name":"Bash","input":{"command":"pytest tests/unit"}}]"#;
1407 let asst_line = assistant_with_content("msg_1", "2026-04-14T08:27:38.895Z", content);
1408 let (_tmp, path) = write_jsonl(&[&user_line, &asst_line]);
1409 let entries = parse_jsonl_entries(&path).unwrap().entries;
1410 assert_eq!(entries[0].activity, Activity::Testing);
1411 }
1412
1413 #[test]
1414 fn parse_classifies_bash_git_as_gitops() {
1415 let content = r#"[{"type":"tool_use","id":"tu_1","name":"Bash","input":{"command":"git push origin main"}}]"#;
1416 let line = assistant_with_content("msg_1", "2026-04-14T08:27:38.895Z", content);
1417 let (_tmp, path) = write_jsonl(&[&line]);
1418 let entries = parse_jsonl_entries(&path).unwrap().entries;
1419 assert_eq!(entries[0].activity, Activity::GitOps);
1420 }
1421
1422 #[test]
1423 fn parse_tool_result_row_does_not_overwrite_user_text() {
1424 let user_real = r#"{"type":"user","timestamp":"2026-04-14T08:27:30.000Z","message":{"role":"user","content":"refactor the usage store"}}"#.to_string();
1429 let asst1 = assistant_with_content(
1430 "msg_1",
1431 "2026-04-14T08:27:35.000Z",
1432 r#"[{"type":"tool_use","id":"tu_1","name":"Read","input":{"file_path":"store.ts"}}]"#,
1433 );
1434 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();
1435 let asst2 = assistant_with_content(
1436 "msg_2",
1437 "2026-04-14T08:27:40.000Z",
1438 r#"[{"type":"tool_use","id":"tu_2","name":"Edit","input":{"file_path":"store.ts"}}]"#,
1439 );
1440 let (_tmp, path) = write_jsonl(&[&user_real, &asst1, &tool_result, &asst2]);
1441 let entries = parse_jsonl_entries(&path).unwrap().entries;
1442 assert_eq!(entries.len(), 2);
1443 assert_eq!(entries[0].activity, Activity::Exploration);
1445 assert_eq!(entries[1].activity, Activity::Refactor);
1448 }
1449
1450 #[test]
1451 fn parse_classifies_agent_turn_as_delegation() {
1452 let content = r#"[{"type":"tool_use","id":"tu_1","name":"Agent","input":{"description":"explore"}}]"#;
1453 let line = assistant_with_content("msg_1", "2026-04-14T08:27:38.895Z", content);
1454 let (_tmp, path) = write_jsonl(&[&line]);
1455 let entries = parse_jsonl_entries(&path).unwrap().entries;
1456 assert_eq!(entries[0].activity, Activity::Delegation);
1457 }
1458
1459 #[test]
1460 fn parse_pure_text_turn_is_conversation() {
1461 let user_line = r#"{"type":"user","timestamp":"2026-04-14T08:27:30.000Z","message":{"role":"user","content":"thanks"}}"#.to_string();
1462 let content = r#"[{"type":"text","text":"You're welcome — anything else?"}]"#;
1463 let asst_line = assistant_with_content("msg_1", "2026-04-14T08:27:38.895Z", content);
1464 let (_tmp, path) = write_jsonl(&[&user_line, &asst_line]);
1465 let entries = parse_jsonl_entries(&path).unwrap().entries;
1466 assert_eq!(entries[0].activity, Activity::Conversation);
1467 assert!(entries[0].text_chars > 0);
1468 }
1469
1470 #[test]
1471 fn parse_user_array_content_extracts_text_blocks() {
1472 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();
1475 let content = r#"[{"type":"tool_use","id":"tu_1","name":"Edit","input":{}}]"#;
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::Debugging);
1480 }
1481
1482 fn assistant_split_row(id: &str, ts: &str, content_json: &str) -> String {
1488 format!(
1489 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}}}}}"#
1490 )
1491 }
1492
1493 #[test]
1494 fn parse_merges_tool_uses_across_split_content_rows() {
1495 let thinking = assistant_split_row(
1501 "msg_01M6AvUVpAFjzSWcXeyGzUwV",
1502 "2026-05-13T09:09:03.500Z",
1503 r#"[{"type":"thinking","thinking":"plan"}]"#,
1504 );
1505 let text = assistant_split_row(
1506 "msg_01M6AvUVpAFjzSWcXeyGzUwV",
1507 "2026-05-13T09:09:03.550Z",
1508 r#"[{"type":"text","text":"Running tools in parallel."}]"#,
1509 );
1510 let tu1 = assistant_split_row(
1511 "msg_01M6AvUVpAFjzSWcXeyGzUwV",
1512 "2026-05-13T09:09:03.600Z",
1513 r#"[{"type":"tool_use","id":"tu_1","name":"mcp__example__list_configs","input":{"area":"rules"}}]"#,
1514 );
1515 let tu2 = assistant_split_row(
1516 "msg_01M6AvUVpAFjzSWcXeyGzUwV",
1517 "2026-05-13T09:09:03.700Z",
1518 r#"[{"type":"tool_use","id":"tu_2","name":"mcp__example__get_report","input":{}}]"#,
1519 );
1520 let tu3 = assistant_split_row(
1521 "msg_01M6AvUVpAFjzSWcXeyGzUwV",
1522 "2026-05-13T09:09:03.800Z",
1523 r#"[{"type":"tool_use","id":"tu_3","name":"Edit","input":{"file_path":"a.rs"}}]"#,
1524 );
1525 let (_tmp, path) = write_jsonl(&[&thinking, &text, &tu1, &tu2, &tu3]);
1526
1527 let entries = parse_jsonl_entries(&path).unwrap().entries;
1528 assert_eq!(entries.len(), 1, "still one entry per message.id");
1529
1530 let e = &entries[0];
1531 assert_eq!(
1532 e.tool_uses,
1533 vec![
1534 "mcp__example__list_configs".to_string(),
1535 "mcp__example__get_report".to_string(),
1536 "Edit".to_string(),
1537 ],
1538 "all tool_use names merged in row order"
1539 );
1540 assert!(e.text_chars > 0, "text from the text row is preserved");
1541
1542 assert_eq!(e.input_tokens, 6);
1546 assert_eq!(e.output_tokens, 746);
1547 assert_eq!(e.cache_read_tokens, 21859);
1548 assert_eq!(e.cache_write_tokens, 33027);
1549 }
1550
1551 #[test]
1552 fn parse_split_row_classifies_against_merged_content() {
1553 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();
1558 let thinking = assistant_split_row(
1559 "msg_split",
1560 "2026-05-13T09:09:01.000Z",
1561 r#"[{"type":"thinking","thinking":"investigating"}]"#,
1562 );
1563 let edit = assistant_split_row(
1564 "msg_split",
1565 "2026-05-13T09:09:02.000Z",
1566 r#"[{"type":"tool_use","id":"tu_1","name":"Edit","input":{"file_path":"auth.rs"}}]"#,
1567 );
1568 let (_tmp, path) = write_jsonl(&[&user, &thinking, &edit]);
1569
1570 let entries = parse_jsonl_entries(&path).unwrap().entries;
1571 assert_eq!(entries.len(), 1);
1572 assert_eq!(entries[0].activity, Activity::Debugging);
1573 assert_eq!(entries[0].tool_uses, vec!["Edit".to_string()]);
1574 }
1575
1576 #[test]
1577 fn parse_split_row_bash_commands_feed_classifier() {
1578 let user = r#"{"type":"user","timestamp":"2026-05-13T09:09:00.000Z","message":{"role":"user","content":"run tests"}}"#.to_string();
1581 let text = assistant_split_row(
1582 "msg_bash",
1583 "2026-05-13T09:09:01.000Z",
1584 r#"[{"type":"text","text":"Running the unit tests."}]"#,
1585 );
1586 let bash = assistant_split_row(
1587 "msg_bash",
1588 "2026-05-13T09:09:02.000Z",
1589 r#"[{"type":"tool_use","id":"tu_1","name":"Bash","input":{"command":"pytest tests/"}}]"#,
1590 );
1591 let (_tmp, path) = write_jsonl(&[&user, &text, &bash]);
1592
1593 let entries = parse_jsonl_entries(&path).unwrap().entries;
1594 assert_eq!(entries[0].activity, Activity::Testing);
1595 }
1596
1597 #[test]
1600 fn parse_extracts_hook_success_events() {
1601 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}}"#;
1602 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}}"#;
1603 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"}}"#;
1605 let asst = assistant_line("msg_01", "claude-opus-4-6", "2026-04-15T18:08:51.000Z", 5, 10, 0, 0);
1606 let (_tmp, path) = write_jsonl(&[hook_line, hook_fail, hook_msg, &asst]);
1607
1608 let parsed = parse_jsonl_entries(&path).unwrap();
1609 assert_eq!(parsed.entries.len(), 1, "assistant entries unaffected");
1610 assert_eq!(parsed.hooks.len(), 2, "two hook_success events");
1611
1612 assert_eq!(parsed.hooks[0].hook_name, "PostToolUse:Edit");
1613 assert_eq!(parsed.hooks[0].hook_event, "PostToolUse");
1614 assert_eq!(parsed.hooks[0].command, "verify-edit.sh");
1615 assert_eq!(parsed.hooks[0].exit_code, 0);
1616 assert_eq!(parsed.hooks[0].duration_ms, 90);
1617
1618 assert_eq!(parsed.hooks[0].tool_use_id.as_deref(), Some("tu_abc"));
1619
1620 assert_eq!(parsed.hooks[1].hook_name, "PreToolUse:Bash");
1621 assert_eq!(parsed.hooks[1].exit_code, 1);
1622 assert_eq!(parsed.hooks[1].duration_ms, 250);
1623 assert_eq!(parsed.hooks[1].tool_use_id.as_deref(), Some("tu_def"));
1624 }
1625
1626 #[test]
1627 fn parse_ignores_attachment_without_hook_data() {
1628 let non_hook = r#"{"type":"attachment","timestamp":"2026-04-14T08:27:26.236Z","attachment":{"type":"file_content","path":"foo.rs"}}"#;
1630 let asst = assistant_line("msg_01", "claude-opus-4-6", "2026-04-14T08:28:00.000Z", 1, 1, 0, 0);
1631 let (_tmp, path) = write_jsonl(&[non_hook, &asst]);
1632
1633 let parsed = parse_jsonl_entries(&path).unwrap();
1634 assert_eq!(parsed.entries.len(), 1);
1635 assert!(parsed.hooks.is_empty());
1636 }
1637
1638 #[test]
1639 fn snake_to_pascal_converts_correctly() {
1640 assert_eq!(snake_to_pascal("stop"), "Stop");
1641 assert_eq!(snake_to_pascal("pre_tool_use"), "PreToolUse");
1642 assert_eq!(snake_to_pascal("post_tool_use"), "PostToolUse");
1643 assert_eq!(snake_to_pascal("session_start"), "SessionStart");
1644 }
1645
1646 #[test]
1647 fn parse_extracts_system_hook_summary_v2() {
1648 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"}"#;
1650 let asst = assistant_line("msg_01", "claude-opus-4-6", "2026-04-25T09:06:13.000Z", 5, 10, 0, 0);
1651 let (_tmp, path) = write_jsonl(&[stop_summary, &asst]);
1652
1653 let parsed = parse_jsonl_entries(&path).unwrap();
1654 assert_eq!(parsed.entries.len(), 1);
1655 assert_eq!(parsed.hooks.len(), 1);
1656
1657 let h = &parsed.hooks[0];
1658 assert_eq!(h.hook_event, "Stop");
1659 assert_eq!(h.command, "Running build checks...");
1660 assert_eq!(h.duration_ms, 5227);
1661 assert_eq!(h.exit_code, 0, "no errors and not prevented = success");
1662 assert_eq!(h.tool_use_id.as_deref(), Some("tu_xyz"));
1663 }
1664
1665 #[test]
1666 fn parse_system_hook_summary_with_errors() {
1667 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"}"#;
1668 let asst = assistant_line("msg_02", "claude-opus-4-6", "2026-04-25T10:00:01.000Z", 1, 1, 0, 0);
1669 let (_tmp, path) = write_jsonl(&[summary, &asst]);
1670
1671 let parsed = parse_jsonl_entries(&path).unwrap();
1672 assert_eq!(parsed.hooks.len(), 1);
1673 assert_eq!(parsed.hooks[0].exit_code, 1, "errors or prevented = failure");
1674 }
1675
1676 #[test]
1677 fn parse_system_hook_summary_multiple_hooks() {
1678 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"}"#;
1679 let asst = assistant_line("msg_03", "claude-opus-4-6", "2026-04-25T11:00:01.000Z", 1, 1, 0, 0);
1680 let (_tmp, path) = write_jsonl(&[summary, &asst]);
1681
1682 let parsed = parse_jsonl_entries(&path).unwrap();
1683 assert_eq!(parsed.hooks.len(), 2);
1684 assert_eq!(parsed.hooks[0].command, "tsc --noEmit");
1685 assert_eq!(parsed.hooks[0].duration_ms, 1200);
1686 assert_eq!(parsed.hooks[1].command, "cargo check");
1687 assert_eq!(parsed.hooks[1].duration_ms, 3500);
1688 assert_eq!(parsed.hooks[0].tool_use_id, parsed.hooks[1].tool_use_id);
1689 }
1690
1691 #[test]
1692 fn hook_name_label_sanitizes_long_multiline_commands() {
1693 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.";
1697 let escaped = serde_json::to_string(long_cmd).unwrap();
1698 let summary = format!(
1699 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"}}"#
1700 );
1701 let asst = assistant_line("msg_goal", "claude-opus-4-6", "2026-04-25T12:00:01.000Z", 1, 1, 0, 0);
1702 let (_tmp, path) = write_jsonl(&[&summary, &asst]);
1703
1704 let parsed = parse_jsonl_entries(&path).unwrap();
1705 assert_eq!(parsed.hooks.len(), 1);
1706 let h = &parsed.hooks[0];
1707
1708 assert_eq!(h.command, long_cmd, "full command preserved on the entry");
1709 assert!(h.hook_name.starts_with("Stop:"), "name still scoped by event");
1710 assert!(h.hook_name.chars().count() <= "Stop:".len() + 65);
1712 assert!(h.hook_name.ends_with('…'), "long commands get ellipsis: {}", h.hook_name);
1713 assert!(!h.hook_name.contains('\n'), "no newlines in bucket key");
1714 }
1715
1716 #[test]
1717 fn hook_name_label_preserves_short_commands() {
1718 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"}"#;
1719 let asst = assistant_line("msg_short", "claude-opus-4-6", "2026-04-25T13:00:01.000Z", 1, 1, 0, 0);
1720 let (_tmp, path) = write_jsonl(&[summary, &asst]);
1721
1722 let parsed = parse_jsonl_entries(&path).unwrap();
1723 assert_eq!(parsed.hooks[0].hook_name, "Stop:cargo check");
1724 }
1725
1726 #[test]
1727 fn parse_handles_both_formats_in_same_file() {
1728 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}}"#;
1730 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"}"#;
1732 let asst = assistant_line("msg_both", "claude-opus-4-6", "2026-04-25T09:07:00.000Z", 1, 1, 0, 0);
1733 let (_tmp, path) = write_jsonl(&[old_hook, new_hook, &asst]);
1734
1735 let parsed = parse_jsonl_entries(&path).unwrap();
1736 assert_eq!(parsed.hooks.len(), 2, "both formats parsed");
1737 assert_eq!(parsed.hooks[0].hook_event, "PostToolUse");
1738 assert_eq!(parsed.hooks[1].hook_event, "Stop");
1739 }
1740
1741 #[allow(clippy::too_many_arguments)]
1744 fn entry_for_cost(
1745 model: &str,
1746 input: u64,
1747 output: u64,
1748 cr: u64,
1749 cw_total: u64,
1750 cw_1h: u64,
1751 web: u64,
1752 speed: Option<&str>,
1753 ) -> UsageEntry {
1754 UsageEntry {
1755 session_id: "s".into(),
1756 timestamp_ms: 0,
1757 model: model.into(),
1758 input_tokens: input,
1759 output_tokens: output,
1760 cache_read_tokens: cr,
1761 cache_write_tokens: cw_total,
1762 cache_write_1h_tokens: cw_1h,
1763 web_search_requests: web,
1764 speed: speed.map(|s| s.to_string()),
1765 tool_uses: Vec::new(),
1766 text_chars: 0,
1767 activity: Activity::General,
1768 entrypoint: None,
1769 }
1770 }
1771
1772 #[test]
1773 fn fable_pricing_all_token_classes() {
1774 let input = entry_for_cost("claude-fable-5", 1_000_000, 0, 0, 0, 0, 0, None);
1776 assert!((cost_for_entry(&input) - 10.0).abs() < 1e-6);
1777 let output = entry_for_cost("claude-fable-5", 0, 1_000_000, 0, 0, 0, 0, None);
1778 assert!((cost_for_entry(&output) - 50.0).abs() < 1e-6);
1779 let read = entry_for_cost("claude-fable-5", 0, 0, 1_000_000, 0, 0, 0, None);
1780 assert!((cost_for_entry(&read) - 1.0).abs() < 1e-6);
1781 let w5m = entry_for_cost("claude-fable-5", 0, 0, 0, 1_000_000, 0, 0, None);
1782 assert!((cost_for_entry(&w5m) - 12.5).abs() < 1e-6);
1783 let w1h = entry_for_cost("claude-fable-5", 0, 0, 0, 1_000_000, 1_000_000, 0, None);
1784 assert!((cost_for_entry(&w1h) - 20.0).abs() < 1e-6);
1785 }
1786
1787 #[test]
1788 fn fable_has_no_fast_premium() {
1789 let fast = entry_for_cost("claude-fable-5", 1_000_000, 0, 0, 0, 0, 0, Some("fast"));
1792 assert!((cost_for_entry(&fast) - 10.0).abs() < 1e-6);
1793 }
1794
1795 #[test]
1796 fn fable_context_window_is_natively_1m() {
1797 assert_eq!(context_window_for_model("claude-fable-5"), 1_000_000);
1799 assert_eq!(context_window_for_model("claude-opus-4-8"), 200_000);
1800 assert_eq!(context_window_for_model("claude-opus-4-8[1m]"), 1_000_000);
1801 }
1802
1803 #[test]
1804 fn cost_splits_5m_and_1h_cache_writes() {
1805 let all_1h = entry_for_cost("claude-opus-4-8", 0, 0, 0, 1_000_000, 1_000_000, 0, None);
1807 assert!((cost_for_entry(&all_1h) - 10.0).abs() < 1e-6);
1808 let all_5m = entry_for_cost("claude-opus-4-8", 0, 0, 0, 1_000_000, 0, 0, None);
1809 assert!((cost_for_entry(&all_5m) - 6.25).abs() < 1e-6);
1810 let mixed = entry_for_cost("claude-opus-4-8", 0, 0, 0, 1_000_000, 400_000, 0, None);
1812 assert!((cost_for_entry(&mixed) - 7.75).abs() < 1e-6);
1813 }
1814
1815 #[test]
1816 fn cost_adds_flat_web_search_fee() {
1817 let e = entry_for_cost("claude-sonnet-4-6", 0, 0, 0, 0, 0, 3, None);
1819 assert!((cost_for_entry(&e) - 0.03).abs() < 1e-9);
1820 }
1821
1822 #[test]
1823 fn fast_mode_multiplier_scales_token_cost_not_web_fee() {
1824 let std = entry_for_cost("claude-opus-4-8", 1_000_000, 0, 0, 0, 0, 0, Some("standard"));
1826 assert!((cost_for_entry(&std) - 5.0).abs() < 1e-6);
1827 let fast = entry_for_cost("claude-opus-4-8", 1_000_000, 0, 0, 0, 0, 0, Some("fast"));
1828 assert!((cost_for_entry(&fast) - 10.0).abs() < 1e-6);
1829 let fast46 = entry_for_cost("claude-opus-4-6", 1_000_000, 0, 0, 0, 0, 0, Some("fast"));
1831 assert!((cost_for_entry(&fast46) - 30.0).abs() < 1e-6);
1832 let fast_web = entry_for_cost("claude-opus-4-8", 1_000_000, 0, 0, 0, 0, 1, Some("fast"));
1834 assert!((cost_for_entry(&fast_web) - 10.01).abs() < 1e-6);
1835 }
1836
1837 #[test]
1838 fn fast_multiplier_only_for_known_opus() {
1839 assert_eq!(fast_multiplier_for_model("claude-opus-4-8"), 2.0);
1840 assert_eq!(fast_multiplier_for_model("claude-opus-4-7"), 6.0);
1841 assert_eq!(fast_multiplier_for_model("claude-opus-4-6"), 6.0);
1842 assert_eq!(fast_multiplier_for_model("claude-sonnet-4-6"), 1.0);
1843 assert_eq!(fast_multiplier_for_model("claude-opus-5-0"), 1.0);
1845 }
1846
1847 #[test]
1848 fn parse_extracts_cache_split_speed_and_web_search() {
1849 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"}}}"#;
1850 let (_tmp, path) = write_jsonl(&[line]);
1851 let entries = parse_jsonl_entries(&path).unwrap().entries;
1852 assert_eq!(entries.len(), 1);
1853 let e = &entries[0];
1854 assert_eq!(e.cache_write_tokens, 40702, "flat total preserved (== 5m+1h)");
1855 assert_eq!(e.cache_write_1h_tokens, 40002);
1856 assert_eq!(e.web_search_requests, 2);
1857 assert_eq!(e.speed.as_deref(), Some("fast"));
1858 }
1859
1860 #[test]
1861 fn parse_cache_write_takes_max_of_flat_and_split() {
1862 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}}}}"#;
1864 let (_tmp, path) = write_jsonl(&[line]);
1865 let e = &parse_jsonl_entries(&path).unwrap().entries[0];
1866 assert_eq!(e.cache_write_tokens, 1400, "max(100, 500+900)");
1867 assert_eq!(e.cache_write_1h_tokens, 900);
1868 }
1869
1870 #[test]
1871 fn parse_missing_nested_cache_falls_back_to_flat() {
1872 let line = assistant_line("msg_3", "claude-opus-4-8", "2026-05-29T10:00:00.000Z", 5, 10, 0, 30000);
1874 let (_tmp, path) = write_jsonl(&[&line]);
1875 let e = &parse_jsonl_entries(&path).unwrap().entries[0];
1876 assert_eq!(e.cache_write_tokens, 30000);
1877 assert_eq!(e.cache_write_1h_tokens, 0);
1878 assert_eq!(e.web_search_requests, 0);
1879 assert_eq!(e.speed, None);
1880 }
1881
1882 #[test]
1883 fn reduce_session_prices_1h_cache_premium() {
1884 let e = entry_for_cost("claude-opus-4-8", 0, 0, 0, 1_000_000, 1_000_000, 0, None);
1886 let u = reduce_session(&[e], "", "sid").unwrap();
1887 assert!((u.estimated_cost_usd - 10.0).abs() < 1e-6);
1888 }
1889}