1use crate::session::{
2 AssistantContentItem, Entry, ToolResultContent, UserContent, UserContentItem,
3};
4use std::collections::HashMap;
5
6pub(crate) const LABEL_PREVIEW_WIDTH: usize = 60;
7pub(crate) const RESULT_PREVIEW_WIDTH: usize = 50;
8
9#[derive(Debug, Clone, Default, serde::Serialize)]
10pub struct Step {
11 pub label: String,
12 pub detail: String,
13 pub kind: StepKind,
14 pub tool_name: Option<String>,
15 pub timestamp_ms: Option<u64>,
16 pub duration_ms: Option<u64>,
17 pub model: Option<String>,
20 pub tokens_in: Option<u64>,
23 pub tokens_out: Option<u64>,
25 pub cache_read: Option<u64>,
28 pub cache_create: Option<u64>,
30 #[serde(default)]
37 pub is_fork_root: bool,
38 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub tool_call_id: Option<String>,
47}
48
49impl Step {
50 #[must_use]
54 pub fn cost_usd(&self) -> Option<f64> {
55 crate::pricing::cost_usd(
56 self.model.as_deref(),
57 self.tokens_in,
58 self.tokens_out,
59 self.cache_read,
60 self.cache_create,
61 )
62 }
63}
64
65#[derive(Debug, Default, serde::Serialize)]
69pub struct SessionTotals {
70 pub tokens_in: u64,
71 pub tokens_out: u64,
72 pub cache_read: u64,
73 pub cache_create: u64,
74 pub cost_usd: Option<f64>,
75 pub unique_models: Vec<String>,
76}
77
78impl SessionTotals {
79 #[must_use]
80 pub fn has_tokens(&self) -> bool {
81 self.tokens_in > 0 || self.tokens_out > 0 || self.cache_read > 0 || self.cache_create > 0
82 }
83}
84
85#[must_use]
89pub fn compute_session_totals(steps: &[Step]) -> SessionTotals {
90 let mut t = SessionTotals::default();
91 let mut models: Vec<String> = Vec::new();
92 let mut any_cost: Option<f64> = None;
93 for step in steps {
94 t.tokens_in += step.tokens_in.unwrap_or(0);
95 t.tokens_out += step.tokens_out.unwrap_or(0);
96 t.cache_read += step.cache_read.unwrap_or(0);
97 t.cache_create += step.cache_create.unwrap_or(0);
98 if let Some(m) = &step.model
99 && !models.iter().any(|existing| existing == m)
100 {
101 models.push(m.clone());
102 }
103 if let Some(c) = step.cost_usd() {
104 any_cost = Some(any_cost.unwrap_or(0.0) + c);
105 }
106 }
107 t.cost_usd = any_cost;
108 t.unique_models = models;
109 t
110}
111
112#[derive(Debug, Clone, Default)]
117pub(crate) struct Usage {
118 pub tokens_in: Option<u64>,
119 pub tokens_out: Option<u64>,
120 pub cache_read: Option<u64>,
121 pub cache_create: Option<u64>,
122}
123
124impl Usage {
125 pub fn is_empty(&self) -> bool {
126 self.tokens_in.is_none()
127 && self.tokens_out.is_none()
128 && self.cache_read.is_none()
129 && self.cache_create.is_none()
130 }
131}
132
133pub(crate) fn attach_usage_to_first(
138 steps: &mut [Step],
139 start: usize,
140 model: Option<&str>,
141 usage: &Usage,
142) {
143 if let Some(step) = steps.get_mut(start) {
144 if let Some(m) = model {
145 step.model = Some(m.to_string());
146 }
147 if !usage.is_empty() {
148 step.tokens_in = usage.tokens_in;
149 step.tokens_out = usage.tokens_out;
150 step.cache_read = usage.cache_read;
151 step.cache_create = usage.cache_create;
152 }
153 }
154}
155
156#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize)]
163#[serde(rename_all = "snake_case")]
164#[non_exhaustive]
165pub enum StepKind {
166 #[default]
167 UserText,
168 ToolResult,
169 AssistantText,
170 ToolUse,
171}
172
173#[derive(Debug, Default)]
174pub struct StepCounts {
175 pub user: usize,
176 pub assistant: usize,
177 pub tool_uses: usize,
178 pub tool_results: usize,
179}
180
181pub fn is_error_result(step: &Step) -> bool {
187 const INDICATORS: &[&str] = &[
194 "\"error\"",
195 "error:",
196 " failed",
197 "\nfailed",
198 "traceback",
199 "panic!",
200 "exception:",
201 "no such file",
202 "permission denied",
203 "command failed",
204 ];
205 if step.kind != StepKind::ToolResult {
206 return false;
207 }
208 let haystack = step
209 .detail
210 .split("\nResult:\n")
211 .nth(1)
212 .unwrap_or(&step.detail)
213 .to_lowercase();
214 INDICATORS.iter().any(|kw| haystack.contains(kw)) || haystack_has_nonzero_exit_code(&haystack)
215}
216
217fn haystack_has_nonzero_exit_code(haystack: &str) -> bool {
223 const MARKERS: &[&str] = &["exit code ", "process exited with code "];
224 for marker in MARKERS {
225 let mut rest = haystack;
226 while let Some(idx) = rest.find(marker) {
227 let after = &rest[idx + marker.len()..];
228 let digit_end = after
230 .as_bytes()
231 .iter()
232 .position(|b| !b.is_ascii_digit())
233 .unwrap_or(after.len());
234 if digit_end > 0
235 && let Ok(code) = after[..digit_end].parse::<u32>()
236 && code != 0
237 {
238 return true;
239 }
240 rest = &after[digit_end..];
241 }
242 }
243 false
244}
245
246pub fn count_from_steps(steps: &[Step]) -> StepCounts {
247 let mut c = StepCounts::default();
248 for step in steps {
249 match step.kind {
250 StepKind::UserText => c.user += 1,
251 StepKind::AssistantText => c.assistant += 1,
252 StepKind::ToolUse => c.tool_uses += 1,
253 StepKind::ToolResult => c.tool_results += 1,
254 }
255 }
256 c
257}
258
259#[derive(Debug, Clone, Default, serde::Serialize)]
260pub struct ToolStats {
261 pub name: String,
262 pub use_count: usize,
263 pub result_count: usize,
264 pub error_count: usize,
265}
266
267impl ToolStats {
268 pub fn error_rate(&self) -> Option<f64> {
269 if self.result_count == 0 {
270 None
271 } else {
272 #[allow(clippy::cast_precision_loss)]
273 Some(self.error_count as f64 / self.result_count as f64)
274 }
275 }
276}
277
278pub fn compute_tool_stats(steps: &[Step]) -> Vec<ToolStats> {
281 let mut map: HashMap<String, ToolStats> = HashMap::new();
282 for step in steps {
283 let Some(name) = &step.tool_name else {
284 continue;
285 };
286 let entry = map.entry(name.clone()).or_insert_with(|| ToolStats {
287 name: name.clone(),
288 ..ToolStats::default()
289 });
290 match step.kind {
291 StepKind::ToolUse => entry.use_count += 1,
292 StepKind::ToolResult => {
293 entry.result_count += 1;
294 if is_error_result(step) {
295 entry.error_count += 1;
296 }
297 }
298 _ => {}
299 }
300 }
301 let mut stats: Vec<ToolStats> = map.into_values().collect();
302 stats.sort_by(|a, b| {
303 b.use_count
304 .cmp(&a.use_count)
305 .then_with(|| a.name.cmp(&b.name))
306 });
307 stats
308}
309
310#[derive(Debug, Clone)]
311struct ToolMeta {
312 name: String,
313 input_pretty: String,
314}
315
316fn collect_fork_root_uuids(entries: &[Entry]) -> std::collections::HashSet<&str> {
328 use std::collections::HashMap;
329 let mut children_by_parent: HashMap<Option<&str>, Vec<&str>> = HashMap::new();
330 for entry in entries {
331 let (uuid, parent) = match entry {
332 Entry::User(u) => (u.uuid.as_str(), u.parent_uuid.as_deref()),
333 Entry::Assistant(a) => (a.uuid.as_str(), a.parent_uuid.as_deref()),
334 Entry::Other => continue,
335 };
336 children_by_parent.entry(parent).or_default().push(uuid);
337 }
338 children_by_parent
339 .into_iter()
340 .filter(|(_, children)| children.len() > 1)
341 .flat_map(|(_, children)| children.into_iter())
342 .collect()
343}
344
345#[must_use]
348pub fn fork_root_count(steps: &[Step]) -> usize {
349 steps.iter().filter(|s| s.is_fork_root).count()
350}
351
352#[must_use]
356pub fn fork_root_indices(steps: &[Step]) -> Vec<usize> {
357 steps
358 .iter()
359 .enumerate()
360 .filter(|(_, s)| s.is_fork_root)
361 .map(|(i, _)| i)
362 .collect()
363}
364
365pub fn build(entries: &[Entry]) -> Vec<Step> {
366 let tool_meta = collect_tool_meta(entries);
367 let fork_uuids = collect_fork_root_uuids(entries);
374 let mut steps = Vec::new();
375 for entry in entries {
376 match entry {
377 Entry::User(u) => {
378 let ts = u.timestamp.as_deref().and_then(parse_iso_ms);
379 let is_fork = fork_uuids.contains(u.uuid.as_str());
380 let entry_first_idx = steps.len();
381 match &u.message.content {
382 UserContent::Text(text) => {
383 let mut step = user_text_step(text);
384 step.timestamp_ms = ts;
385 steps.push(step);
386 }
387 UserContent::Items(items) => {
388 for item in items {
389 match item {
390 UserContentItem::Text { text } => {
391 let mut step = user_text_step(text);
392 step.timestamp_ms = ts;
393 steps.push(step);
394 }
395 UserContentItem::ToolResult {
396 tool_use_id,
397 content,
398 } => {
399 let result_text = match content {
400 ToolResultContent::Text(s) => s.clone(),
401 ToolResultContent::Items(v) => pretty_json(v),
402 };
403 let meta = tool_meta.get(tool_use_id);
404 let mut step = tool_result_step(
405 tool_use_id,
406 &result_text,
407 meta.map(|m| m.name.as_str()),
408 meta.map(|m| m.input_pretty.as_str()),
409 );
410 step.timestamp_ms = ts;
411 steps.push(step);
412 }
413 UserContentItem::Other => {}
414 }
415 }
416 }
417 }
418 if is_fork && let Some(first) = steps.get_mut(entry_first_idx) {
419 first.is_fork_root = true;
420 }
421 }
422 Entry::Assistant(a) => {
423 let ts = a.timestamp.as_deref().and_then(parse_iso_ms);
424 let first_idx = steps.len();
425 let is_fork = fork_uuids.contains(a.uuid.as_str());
426 for item in &a.message.content {
427 match item {
428 AssistantContentItem::Text { text } => {
429 let mut step = assistant_text_step(text);
430 step.timestamp_ms = ts;
431 steps.push(step);
432 }
433 AssistantContentItem::ToolUse { id, name, input } => {
434 let input_pretty = pretty_json(input);
435 let mut step = tool_use_step(id, name, &input_pretty);
436 step.timestamp_ms = ts;
437 steps.push(step);
438 }
439 AssistantContentItem::Other => {}
440 }
441 }
442 if is_fork && let Some(first) = steps.get_mut(first_idx) {
443 first.is_fork_root = true;
444 }
445 if steps.len() > first_idx {
450 let usage = a
451 .message
452 .usage
453 .as_ref()
454 .map(|u| Usage {
455 tokens_in: u.input_tokens,
456 tokens_out: u.output_tokens,
457 cache_read: u.cache_read_input_tokens,
458 cache_create: u.cache_creation_input_tokens,
459 })
460 .unwrap_or_default();
461 attach_usage_to_first(
462 &mut steps,
463 first_idx,
464 a.message.model.as_deref(),
465 &usage,
466 );
467 }
468 }
469 Entry::Other => {}
470 }
471 }
472 compute_durations(&mut steps);
473 steps
474}
475
476pub fn user_text_step(text: &str) -> Step {
477 Step {
478 label: format!("[user] {}", truncate(text, LABEL_PREVIEW_WIDTH)),
479 detail: text.to_string(),
480 kind: StepKind::UserText,
481 ..Step::default()
482 }
483}
484
485pub fn assistant_text_step(text: &str) -> Step {
486 Step {
487 label: format!("[asst] {}", truncate(text, LABEL_PREVIEW_WIDTH)),
488 detail: text.to_string(),
489 kind: StepKind::AssistantText,
490 ..Step::default()
491 }
492}
493
494pub fn tool_use_step(id: &str, name: &str, input_pretty: &str) -> Step {
495 Step {
496 label: format!("[tool] {} ({})", name, short_id(id)),
497 detail: format!("Tool: {name}\nID: {id}\n\nInput:\n{input_pretty}"),
498 kind: StepKind::ToolUse,
499 tool_name: Some(name.to_string()),
500 tool_call_id: Some(id.to_string()),
501 ..Step::default()
502 }
503}
504
505pub fn tool_result_step(
506 id: &str,
507 result: &str,
508 tool_name: Option<&str>,
509 input_pretty: Option<&str>,
510) -> Step {
511 let display_name = tool_name.unwrap_or("(unknown)");
512 let input_section = input_pretty
513 .map(|p| format!("Input:\n{p}\n\n"))
514 .unwrap_or_default();
515 Step {
516 label: format!(
517 "[result] {} → {}",
518 display_name,
519 truncate(result, RESULT_PREVIEW_WIDTH)
520 ),
521 detail: format!("Tool: {display_name}\nID: {id}\n\n{input_section}Result:\n{result}"),
522 kind: StepKind::ToolResult,
523 tool_name: tool_name.map(str::to_string),
524 tool_call_id: Some(id.to_string()),
525 ..Step::default()
526 }
527}
528
529pub fn compute_durations(steps: &mut [Step]) {
531 for i in 1..steps.len() {
532 if let (Some(prev), Some(cur)) = (steps[i - 1].timestamp_ms, steps[i].timestamp_ms)
533 && cur >= prev
534 {
535 steps[i].duration_ms = Some(cur - prev);
536 }
537 }
538}
539
540#[allow(clippy::cast_precision_loss)]
542pub fn format_duration_ms(ms: u64) -> String {
543 if ms < 1_000 {
544 format!("{ms}ms")
545 } else if ms < 60_000 {
546 format!("{:.1}s", ms as f64 / 1_000.0)
547 } else {
548 format!("{:.1}min", ms as f64 / 60_000.0)
549 }
550}
551
552#[allow(
555 clippy::many_single_char_names,
556 clippy::cast_sign_loss,
557 clippy::cast_possible_wrap
558)]
559pub(crate) fn parse_iso_ms(s: &str) -> Option<u64> {
560 if s.len() < 19 {
561 return None;
562 }
563 let y: i64 = s.get(0..4)?.parse().ok()?;
564 let mo: u64 = s.get(5..7)?.parse().ok()?;
565 let d: u64 = s.get(8..10)?.parse().ok()?;
566 let h: u64 = s.get(11..13)?.parse().ok()?;
567 let mi: u64 = s.get(14..16)?.parse().ok()?;
568 let se: u64 = s.get(17..19)?.parse().ok()?;
569
570 let (adj_y, adj_m) = if mo <= 2 {
572 (y - 1, mo + 9)
573 } else {
574 (y, mo - 3)
575 };
576 let era = if adj_y >= 0 { adj_y } else { adj_y - 399 } / 400;
577 let yoe = (adj_y - era * 400) as u64;
578 let doy = (153 * adj_m + 2) / 5 + d - 1;
579 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
580 let days = (era * 146_097 + doe as i64 - 719_468) as u64;
581
582 let secs = days * 86_400 + h * 3_600 + mi * 60 + se;
583
584 let bytes = s.as_bytes();
586 let ms = if bytes.len() > 19 && bytes[19] == b'.' {
587 let end = bytes[20..]
588 .iter()
589 .position(|c| !c.is_ascii_digit())
590 .map_or(bytes.len(), |p| 20 + p);
591 let frac = s.get(20..end)?;
592 if frac.is_empty() {
593 0
594 } else {
595 let mut val: u64 = frac.parse().ok()?;
596 match frac.len() {
597 1 => val *= 100,
598 2 => val *= 10,
599 3 => {}
600 n => {
601 val /= 10u64.pow(u32::try_from(n - 3).unwrap_or(0));
602 }
603 }
604 val
605 }
606 } else {
607 0
608 };
609
610 Some(secs * 1_000 + ms)
611}
612
613fn collect_tool_meta(entries: &[Entry]) -> HashMap<String, ToolMeta> {
614 let mut map = HashMap::new();
615 for entry in entries {
616 if let Entry::Assistant(a) = entry {
617 for item in &a.message.content {
618 if let AssistantContentItem::ToolUse { id, name, input } = item {
619 map.insert(
620 id.clone(),
621 ToolMeta {
622 name: name.clone(),
623 input_pretty: pretty_json(input),
624 },
625 );
626 }
627 }
628 }
629 }
630 map
631}
632
633pub(crate) fn pretty_json<T: serde::Serialize>(value: &T) -> String {
634 serde_json::to_string_pretty(value).unwrap_or_default()
635}
636
637pub fn truncate(s: &str, n: usize) -> String {
638 let mut head = String::with_capacity(n);
639 let mut iter = s.chars().map(|c| if c == '\n' { ' ' } else { c });
640 for _ in 0..n {
641 match iter.next() {
642 Some(c) => head.push(c),
643 None => return head,
644 }
645 }
646 if iter.next().is_some() {
647 head.push('…');
648 }
649 head
650}
651
652pub(crate) fn short_id(id: &str) -> String {
653 if id.chars().count() <= 12 {
660 id.to_string()
661 } else {
662 let head: String = id.chars().take(11).collect();
663 format!("{head}…")
664 }
665}
666
667#[cfg(test)]
668mod tests {
669 use super::*;
670 use crate::session::{AssistantEntry, AssistantMessage, UserContent, UserEntry, UserMessage};
671
672 #[test]
673 fn builds_steps_from_user_and_assistant() {
674 let entries = vec![
675 Entry::User(UserEntry {
676 uuid: "u1".into(),
677 parent_uuid: None,
678 timestamp: None,
679 message: UserMessage {
680 role: "user".into(),
681 content: UserContent::Text("hello world".into()),
682 },
683 }),
684 Entry::Assistant(AssistantEntry {
685 uuid: "a1".into(),
686 parent_uuid: Some("u1".into()),
687 timestamp: None,
688 message: AssistantMessage {
689 role: "assistant".into(),
690 model: None,
691 usage: None,
692 content: vec![
693 AssistantContentItem::Text {
694 text: "thinking".into(),
695 },
696 AssistantContentItem::ToolUse {
697 id: "toolu_abc".into(),
698 name: "Read".into(),
699 input: serde_json::json!({"file_path": "/x"}),
700 },
701 ],
702 },
703 }),
704 ];
705 let steps = build(&entries);
706 assert_eq!(steps.len(), 3);
707 assert_eq!(steps[0].kind, StepKind::UserText);
708 assert_eq!(steps[1].kind, StepKind::AssistantText);
709 assert_eq!(steps[2].kind, StepKind::ToolUse);
710 assert!(steps[2].detail.contains("Read"));
711 assert!(steps[2].detail.contains("/x"));
712 }
713
714 #[test]
715 fn usage_attaches_only_to_first_step_from_assistant_message() {
716 use crate::session::{AssistantEntry, AssistantMessage, ClaudeUsage};
720 let entries = vec![Entry::Assistant(AssistantEntry {
721 uuid: "a1".into(),
722 parent_uuid: None,
723 timestamp: None,
724 message: AssistantMessage {
725 role: "assistant".into(),
726 model: Some("claude-opus-4-6".into()),
727 usage: Some(ClaudeUsage {
728 input_tokens: Some(100),
729 output_tokens: Some(50),
730 cache_creation_input_tokens: Some(10),
731 cache_read_input_tokens: Some(200),
732 }),
733 content: vec![
734 AssistantContentItem::Text {
735 text: "thinking".into(),
736 },
737 AssistantContentItem::ToolUse {
738 id: "t1".into(),
739 name: "Read".into(),
740 input: serde_json::json!({"path": "/x"}),
741 },
742 ],
743 },
744 })];
745 let steps = build(&entries);
746 assert_eq!(steps.len(), 2);
747 assert_eq!(steps[0].model.as_deref(), Some("claude-opus-4-6"));
749 assert_eq!(steps[0].tokens_in, Some(100));
750 assert_eq!(steps[0].tokens_out, Some(50));
751 assert_eq!(steps[0].cache_create, Some(10));
752 assert_eq!(steps[0].cache_read, Some(200));
753 assert_eq!(steps[1].model, None);
755 assert_eq!(steps[1].tokens_in, None);
756 assert_eq!(steps[1].tokens_out, None);
757 }
758
759 #[test]
760 fn missing_usage_leaves_all_steps_clean() {
761 use crate::session::{AssistantEntry, AssistantMessage};
762 let entries = vec![Entry::Assistant(AssistantEntry {
763 uuid: "a1".into(),
764 parent_uuid: None,
765 timestamp: None,
766 message: AssistantMessage {
767 role: "assistant".into(),
768 model: None,
769 usage: None,
770 content: vec![AssistantContentItem::Text { text: "ok".into() }],
771 },
772 })];
773 let steps = build(&entries);
774 assert_eq!(steps[0].tokens_in, None);
775 assert_eq!(steps[0].model, None);
776 }
777
778 #[test]
779 fn attach_usage_to_first_noop_on_empty_slice() {
780 let mut steps: Vec<Step> = Vec::new();
781 attach_usage_to_first(&mut steps, 0, Some("m"), &Usage::default());
783 }
784
785 fn user_entry(uuid: &str, parent: Option<&str>, text: &str) -> Entry {
788 Entry::User(UserEntry {
789 uuid: uuid.into(),
790 parent_uuid: parent.map(str::to_string),
791 timestamp: None,
792 message: UserMessage {
793 role: "user".into(),
794 content: UserContent::Text(text.into()),
795 },
796 })
797 }
798
799 fn asst_entry(uuid: &str, parent: Option<&str>, text: &str) -> Entry {
800 Entry::Assistant(AssistantEntry {
801 uuid: uuid.into(),
802 parent_uuid: parent.map(str::to_string),
803 timestamp: None,
804 message: AssistantMessage {
805 role: "assistant".into(),
806 model: None,
807 usage: None,
808 content: vec![AssistantContentItem::Text { text: text.into() }],
809 },
810 })
811 }
812
813 #[test]
814 fn linear_session_has_no_fork_roots() {
815 let entries = vec![
816 user_entry("u1", None, "hi"),
817 asst_entry("a1", Some("u1"), "hello"),
818 user_entry("u2", Some("a1"), "and"),
819 asst_entry("a2", Some("u2"), "ok"),
820 ];
821 let steps = build(&entries);
822 assert_eq!(fork_root_count(&steps), 0);
823 assert!(fork_root_indices(&steps).is_empty());
824 }
825
826 #[test]
827 fn two_siblings_of_same_parent_are_fork_roots() {
828 let entries = vec![
831 user_entry("u1", None, "prompt"),
832 asst_entry("a1", Some("u1"), "first reply"),
833 asst_entry("a2", Some("u1"), "second reply"),
834 ];
835 let steps = build(&entries);
836 assert_eq!(fork_root_count(&steps), 2);
837 assert!(steps[1].is_fork_root);
838 assert!(steps[2].is_fork_root);
839 assert!(!steps[0].is_fork_root);
840 }
841
842 #[test]
843 fn multiple_root_entries_are_all_fork_roots() {
844 let entries = vec![
847 user_entry("u1", None, "thread 1"),
848 user_entry("u2", None, "thread 2"),
849 ];
850 let steps = build(&entries);
851 assert_eq!(fork_root_count(&steps), 2);
852 assert_eq!(fork_root_indices(&steps), vec![0, 1]);
853 }
854
855 #[test]
856 fn single_root_entry_is_not_a_fork() {
857 let entries = vec![user_entry("u1", None, "only thread")];
858 let steps = build(&entries);
859 assert_eq!(fork_root_count(&steps), 0);
860 }
861
862 #[test]
863 fn fork_root_marker_only_on_first_emitted_step() {
864 use crate::session::{AssistantEntry, AssistantMessage};
867 let entries = vec![
868 user_entry("u1", None, "prompt"),
869 Entry::Assistant(AssistantEntry {
870 uuid: "a1".into(),
871 parent_uuid: Some("u1".into()),
872 timestamp: None,
873 message: AssistantMessage {
874 role: "assistant".into(),
875 model: None,
876 usage: None,
877 content: vec![
878 AssistantContentItem::Text {
879 text: "thinking".into(),
880 },
881 AssistantContentItem::ToolUse {
882 id: "t1".into(),
883 name: "Read".into(),
884 input: serde_json::json!({}),
885 },
886 ],
887 },
888 }),
889 asst_entry("a2", Some("u1"), "alt reply"),
890 ];
891 let steps = build(&entries);
892 assert_eq!(steps.len(), 4);
894 assert!(steps[1].is_fork_root, "first step from a1 should be marked");
895 assert!(
896 !steps[2].is_fork_root,
897 "subsequent step from same entry should not be marked"
898 );
899 assert!(steps[3].is_fork_root);
900 }
901
902 #[test]
903 fn usage_is_empty_detects_all_none() {
904 assert!(Usage::default().is_empty());
905 let u = Usage {
906 tokens_in: Some(1),
907 ..Usage::default()
908 };
909 assert!(!u.is_empty());
910 }
911
912 #[test]
913 fn step_cost_usd_delegates_to_pricing_table() {
914 let mut step = assistant_text_step("hi");
915 step.model = Some("claude-opus-4-6".into());
916 step.tokens_in = Some(1_000_000);
917 step.tokens_out = Some(1_000_000);
918 let c = step.cost_usd().unwrap();
919 assert!((c - 90.0).abs() < 1e-6);
920 }
921
922 #[test]
923 fn step_cost_usd_none_when_no_model() {
924 let mut step = assistant_text_step("hi");
925 step.tokens_in = Some(100);
926 assert_eq!(step.cost_usd(), None);
927 }
928
929 #[test]
930 fn step_cost_usd_none_when_no_tokens() {
931 let mut step = assistant_text_step("hi");
932 step.model = Some("claude-opus-4-6".into());
933 assert_eq!(step.cost_usd(), None);
934 }
935
936 #[test]
937 fn session_totals_sums_tokens_and_costs_across_steps() {
938 let mut s1 = assistant_text_step("a");
939 s1.model = Some("claude-opus-4-6".into());
940 s1.tokens_in = Some(100);
941 s1.tokens_out = Some(50);
942 let mut s2 = assistant_text_step("b");
943 s2.model = Some("claude-opus-4-6".into());
944 s2.tokens_in = Some(200);
945 s2.tokens_out = Some(75);
946 let t = compute_session_totals(&[s1, s2]);
947 assert_eq!(t.tokens_in, 300);
948 assert_eq!(t.tokens_out, 125);
949 assert_eq!(t.unique_models, vec!["claude-opus-4-6"]);
950 assert!(t.cost_usd.is_some());
951 }
952
953 #[test]
954 fn session_totals_dedupes_unique_models() {
955 let mut s1 = assistant_text_step("a");
956 s1.model = Some("claude-opus-4-6".into());
957 let mut s2 = assistant_text_step("b");
958 s2.model = Some("claude-sonnet-4-6".into());
959 let mut s3 = assistant_text_step("c");
960 s3.model = Some("claude-opus-4-6".into());
961 let t = compute_session_totals(&[s1, s2, s3]);
962 assert_eq!(t.unique_models.len(), 2);
963 assert!(t.unique_models.contains(&"claude-opus-4-6".to_string()));
964 assert!(t.unique_models.contains(&"claude-sonnet-4-6".to_string()));
965 }
966
967 #[test]
968 fn session_totals_cost_none_when_no_known_models() {
969 let mut s = assistant_text_step("a");
970 s.model = Some("unknown-model".into());
971 s.tokens_in = Some(100);
972 let t = compute_session_totals(&[s]);
973 assert_eq!(t.tokens_in, 100);
974 assert_eq!(t.cost_usd, None);
975 }
976
977 #[test]
978 fn session_totals_has_tokens_false_on_empty() {
979 let t = SessionTotals::default();
980 assert!(!t.has_tokens());
981 }
982
983 #[test]
984 fn session_totals_has_tokens_true_when_any_counter_set() {
985 let t = SessionTotals {
986 tokens_in: 1,
987 ..SessionTotals::default()
988 };
989 assert!(t.has_tokens());
990 }
991
992 #[test]
993 fn tool_result_label_uses_tool_name_from_paired_use() {
994 let entries = vec![
995 Entry::Assistant(AssistantEntry {
996 uuid: "a1".into(),
997 parent_uuid: None,
998 timestamp: None,
999 message: AssistantMessage {
1000 role: "assistant".into(),
1001 model: None,
1002 usage: None,
1003 content: vec![AssistantContentItem::ToolUse {
1004 id: "toolu_abc".into(),
1005 name: "Bash".into(),
1006 input: serde_json::json!({"command": "ls"}),
1007 }],
1008 },
1009 }),
1010 Entry::User(UserEntry {
1011 uuid: "u2".into(),
1012 parent_uuid: Some("a1".into()),
1013 timestamp: None,
1014 message: UserMessage {
1015 role: "user".into(),
1016 content: UserContent::Items(vec![UserContentItem::ToolResult {
1017 tool_use_id: "toolu_abc".into(),
1018 content: ToolResultContent::Text("file1\nfile2".into()),
1019 }]),
1020 },
1021 }),
1022 ];
1023 let steps = build(&entries);
1024 assert_eq!(steps.len(), 2);
1025 assert_eq!(steps[1].kind, StepKind::ToolResult);
1026 assert!(
1027 steps[1].label.contains("Bash"),
1028 "expected label to include tool name, got: {}",
1029 steps[1].label
1030 );
1031 assert!(steps[1].detail.contains("Tool: Bash"));
1032 assert!(steps[1].detail.contains("Input:"));
1033 assert!(steps[1].detail.contains("\"command\""));
1034 assert!(steps[1].detail.contains("Result:"));
1035 assert!(steps[1].detail.contains("file1"));
1036 }
1037
1038 #[test]
1039 fn tool_result_falls_back_when_no_paired_use() {
1040 let entries = vec![Entry::User(UserEntry {
1041 uuid: "u1".into(),
1042 parent_uuid: None,
1043 timestamp: None,
1044 message: UserMessage {
1045 role: "user".into(),
1046 content: UserContent::Items(vec![UserContentItem::ToolResult {
1047 tool_use_id: "toolu_orphan".into(),
1048 content: ToolResultContent::Text("output".into()),
1049 }]),
1050 },
1051 })];
1052 let steps = build(&entries);
1053 assert_eq!(steps.len(), 1);
1054 assert!(steps[0].label.contains("(unknown)"));
1055 assert!(steps[0].detail.contains("Tool: (unknown)"));
1056 assert!(!steps[0].detail.contains("Input:"));
1057 assert!(steps[0].detail.contains("Result:"));
1058 }
1059
1060 #[test]
1061 fn count_from_steps_works() {
1062 let steps = vec![
1063 user_text_step("hi"),
1064 assistant_text_step("hello"),
1065 tool_use_step("id1", "Read", "{}"),
1066 tool_result_step("id1", "output", Some("Read"), Some("{}")),
1067 tool_use_step("id2", "Bash", "{}"),
1068 ];
1069 let c = count_from_steps(&steps);
1070 assert_eq!(c.user, 1);
1071 assert_eq!(c.assistant, 1);
1072 assert_eq!(c.tool_uses, 2);
1073 assert_eq!(c.tool_results, 1);
1074 }
1075
1076 #[test]
1077 fn truncate_handles_short_strings() {
1078 assert_eq!(truncate("hi", 10), "hi");
1079 }
1080
1081 #[test]
1082 fn truncate_handles_long_strings() {
1083 let s = "a".repeat(20);
1084 assert_eq!(truncate(&s, 5), "aaaaa…");
1085 }
1086
1087 #[test]
1088 fn truncate_replaces_newlines() {
1089 assert_eq!(truncate("a\nb\nc", 10), "a b c");
1090 }
1091
1092 #[test]
1093 fn truncate_handles_exact_length() {
1094 assert_eq!(truncate("abcde", 5), "abcde");
1095 }
1096
1097 #[test]
1098 fn truncate_handles_unicode() {
1099 assert_eq!(truncate("héllo", 3), "hél…");
1100 assert_eq!(truncate("héllo世界", 5), "héllo…");
1101 assert_eq!(truncate("héllo世界", 6), "héllo世…");
1102 assert_eq!(truncate("héllo世界", 7), "héllo世界");
1103 }
1104
1105 #[test]
1106 fn short_id_passes_short_strings_through() {
1107 assert_eq!(short_id(""), "");
1108 assert_eq!(short_id("abc"), "abc");
1109 assert_eq!(short_id("toolu_abcde"), "toolu_abcde");
1110 }
1111
1112 #[test]
1113 fn short_id_truncates_long_strings_at_eleven() {
1114 assert_eq!(short_id("toolu_0123456789xyz"), "toolu_01234…");
1115 assert_eq!(short_id("toolu_abcdefghijkl"), "toolu_abcde…");
1116 }
1117
1118 #[test]
1119 fn short_id_handles_exact_twelve_boundary() {
1120 assert_eq!(short_id("123456789012"), "123456789012");
1121 assert_eq!(short_id("1234567890123"), "12345678901…");
1122 }
1123
1124 #[test]
1125 fn short_id_does_not_panic_on_multibyte_utf8_near_boundary() {
1126 let id = "abcdefghijk😀xyz";
1131 let out = short_id(id);
1132 assert_eq!(out, "abcdefghijk…");
1134 }
1135
1136 fn result_step_with_body(body: &str) -> Step {
1137 tool_result_step("t1", body, Some("Bash"), Some("{}"))
1138 }
1139
1140 #[test]
1141 fn is_error_result_detects_error_keyword() {
1142 let step = result_step_with_body("error: file not found");
1143 assert!(is_error_result(&step));
1144 }
1145
1146 #[test]
1147 fn is_error_result_detects_failed_word() {
1148 let step = result_step_with_body("Command failed with exit code 1");
1149 assert!(is_error_result(&step));
1150 }
1151
1152 #[test]
1153 fn is_error_result_detects_traceback() {
1154 let step = result_step_with_body("Traceback (most recent call last):\n ...");
1155 assert!(is_error_result(&step));
1156 }
1157
1158 #[test]
1159 fn is_error_result_detects_no_such_file() {
1160 let step = result_step_with_body("ls: /nonexistent: No such file or directory");
1161 assert!(is_error_result(&step));
1162 }
1163
1164 #[test]
1165 fn is_error_result_detects_exit_code_nonzero() {
1166 let step = result_step_with_body("Process exited with code 127");
1167 assert!(is_error_result(&step));
1170 }
1171
1172 #[test]
1173 fn is_error_result_ignores_exit_code_zero() {
1174 let step = result_step_with_body("Process exited with code 0\nAll tests passed.");
1178 assert!(!is_error_result(&step));
1179 }
1180
1181 #[test]
1182 fn is_error_result_detects_two_digit_exit_codes() {
1183 let step = result_step_with_body("Bash exited with exit code 10");
1188 assert!(is_error_result(&step));
1189 }
1190
1191 #[test]
1192 fn is_error_result_finds_embedded_exit_code_after_verbose_output() {
1193 let step = result_step_with_body(
1197 "running 3 tests\ntest a ... ok\nok\nshell exited with exit code 42",
1198 );
1199 assert!(is_error_result(&step));
1200 }
1201
1202 #[test]
1203 fn is_error_result_detects_json_error_field() {
1204 let step = result_step_with_body("{\"error\": \"bad request\"}");
1205 assert!(is_error_result(&step));
1206 }
1207
1208 #[test]
1209 fn is_error_result_returns_false_for_clean_output() {
1210 let step = result_step_with_body("[0, 1, 1, 2, 3, 5, 8, 13, 21, 34]");
1211 assert!(!is_error_result(&step));
1212 }
1213
1214 #[test]
1215 fn is_error_result_returns_false_for_non_tool_result() {
1216 let step = user_text_step("error in my user message");
1217 assert!(!is_error_result(&step));
1218 }
1219
1220 #[test]
1221 fn is_error_result_only_checks_result_section() {
1222 let step = tool_result_step(
1224 "t1",
1225 "all good",
1226 Some("Bash"),
1227 Some("{\"command\": \"grep error\"}"),
1228 );
1229 assert!(!is_error_result(&step));
1230 }
1231
1232 #[test]
1233 fn tool_use_step_records_tool_name() {
1234 let s = tool_use_step("t1", "Read", "{}");
1235 assert_eq!(s.tool_name.as_deref(), Some("Read"));
1236 }
1237
1238 #[test]
1239 fn tool_result_step_records_tool_name() {
1240 let s = tool_result_step("t1", "ok", Some("Bash"), Some("{}"));
1241 assert_eq!(s.tool_name.as_deref(), Some("Bash"));
1242 }
1243
1244 #[test]
1245 fn tool_result_step_tool_name_none_for_orphan() {
1246 let s = tool_result_step("t1", "ok", None, None);
1247 assert_eq!(s.tool_name, None);
1248 }
1249
1250 #[test]
1251 fn text_steps_have_no_tool_name() {
1252 assert_eq!(user_text_step("hi").tool_name, None);
1253 assert_eq!(assistant_text_step("ok").tool_name, None);
1254 }
1255
1256 #[test]
1257 fn compute_tool_stats_groups_by_tool_name() {
1258 let steps = vec![
1259 tool_use_step("t1", "Read", "{}"),
1260 tool_result_step("t1", "content", Some("Read"), Some("{}")),
1261 tool_use_step("t2", "Read", "{}"),
1262 tool_result_step("t2", "content2", Some("Read"), Some("{}")),
1263 tool_use_step("t3", "Bash", "{}"),
1264 tool_result_step("t3", "output", Some("Bash"), Some("{}")),
1265 ];
1266 let stats = compute_tool_stats(&steps);
1267 assert_eq!(stats.len(), 2);
1268 assert_eq!(stats[0].name, "Read");
1270 assert_eq!(stats[0].use_count, 2);
1271 assert_eq!(stats[0].result_count, 2);
1272 assert_eq!(stats[0].error_count, 0);
1273 assert_eq!(stats[1].name, "Bash");
1274 assert_eq!(stats[1].use_count, 1);
1275 }
1276
1277 #[test]
1278 fn compute_tool_stats_counts_errors() {
1279 let steps = vec![
1280 tool_use_step("t1", "Bash", "{}"),
1281 tool_result_step("t1", "error: command failed", Some("Bash"), Some("{}")),
1282 tool_use_step("t2", "Bash", "{}"),
1283 tool_result_step("t2", "success", Some("Bash"), Some("{}")),
1284 ];
1285 let stats = compute_tool_stats(&steps);
1286 assert_eq!(stats.len(), 1);
1287 assert_eq!(stats[0].use_count, 2);
1288 assert_eq!(stats[0].error_count, 1);
1289 assert_eq!(stats[0].error_rate(), Some(0.5));
1290 }
1291
1292 #[test]
1293 fn compute_tool_stats_sorts_by_use_count_descending() {
1294 let steps = vec![
1295 tool_use_step("t1", "Apple", "{}"),
1296 tool_use_step("t2", "Banana", "{}"),
1297 tool_use_step("t3", "Banana", "{}"),
1298 tool_use_step("t4", "Banana", "{}"),
1299 tool_use_step("t5", "Cherry", "{}"),
1300 tool_use_step("t6", "Cherry", "{}"),
1301 ];
1302 let stats = compute_tool_stats(&steps);
1303 assert_eq!(stats.len(), 3);
1304 assert_eq!(stats[0].name, "Banana"); assert_eq!(stats[1].name, "Cherry"); assert_eq!(stats[2].name, "Apple"); }
1308
1309 #[test]
1310 fn compute_tool_stats_empty_for_text_only() {
1311 let steps = vec![user_text_step("hi"), assistant_text_step("hello")];
1312 let stats = compute_tool_stats(&steps);
1313 assert!(stats.is_empty());
1314 }
1315
1316 #[test]
1317 fn tool_stats_error_rate_none_when_no_results() {
1318 let stats = ToolStats {
1319 name: "X".into(),
1320 use_count: 1,
1321 result_count: 0,
1322 error_count: 0,
1323 };
1324 assert_eq!(stats.error_rate(), None);
1325 }
1326}