1use normalize_chat_sessions::{ContentBlock, Session};
8use normalize_output::OutputFormatter;
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use std::path::PathBuf;
12
13#[derive(Debug, Clone, Default, Serialize, schemars::JsonSchema, Deserialize)]
15pub struct ToolStats {
16 pub name: String,
17 pub calls: usize,
18 pub errors: usize,
19 pub output_chars: usize,
21}
22
23#[derive(Debug, Clone, Default, Serialize, schemars::JsonSchema, Deserialize)]
25pub struct LargestToolResult {
26 pub tool_name: String,
27 pub chars: usize,
28 pub turn: usize,
29 pub preview: String,
31}
32
33impl ToolStats {
34 pub fn new(name: impl Into<String>) -> Self {
35 Self {
36 name: name.into(),
37 calls: 0,
38 errors: 0,
39 output_chars: 0,
40 }
41 }
42
43 pub fn success_rate(&self) -> f64 {
44 if self.calls == 0 {
45 0.0
46 } else {
47 (self.calls - self.errors) as f64 / self.calls as f64
48 }
49 }
50}
51
52#[derive(Debug, Clone, Default, Serialize, schemars::JsonSchema, Deserialize)]
54pub struct TokenStats {
55 pub total_input: u64,
56 pub total_output: u64,
57 pub cache_read: u64,
58 pub cache_create: u64,
59 pub min_context: u64,
60 pub max_context: u64,
61 pub api_calls: usize,
62}
63
64#[derive(Debug, Clone, Copy)]
66pub struct ModelPricing {
67 pub name: &'static str,
69 pub input_per_mtok: f64,
71 pub output_per_mtok: f64,
73 pub cache_write_per_mtok: f64,
75 pub cache_read_per_mtok: f64,
77}
78
79impl ModelPricing {
80 pub const SONNET_4_5: ModelPricing = ModelPricing {
82 name: "Claude Sonnet 4.5",
83 input_per_mtok: 3.0,
84 output_per_mtok: 15.0,
85 cache_write_per_mtok: 3.75,
86 cache_read_per_mtok: 0.30,
87 };
88
89 pub const SONNET_3_7: ModelPricing = ModelPricing {
90 name: "Claude Sonnet 3.7",
91 input_per_mtok: 3.0,
92 output_per_mtok: 15.0,
93 cache_write_per_mtok: 3.75,
94 cache_read_per_mtok: 0.30,
95 };
96
97 pub const SONNET_3_5: ModelPricing = ModelPricing {
98 name: "Claude Sonnet 3.5",
99 input_per_mtok: 3.0,
100 output_per_mtok: 15.0,
101 cache_write_per_mtok: 3.75,
102 cache_read_per_mtok: 0.30,
103 };
104
105 pub const SONNET_3: ModelPricing = ModelPricing {
106 name: "Claude Sonnet 3",
107 input_per_mtok: 3.0,
108 output_per_mtok: 15.0,
109 cache_write_per_mtok: 3.75,
110 cache_read_per_mtok: 0.30,
111 };
112
113 pub const OPUS_4_5: ModelPricing = ModelPricing {
114 name: "Claude Opus 4.5/4.6",
115 input_per_mtok: 5.0,
116 output_per_mtok: 25.0,
117 cache_write_per_mtok: 6.25,
118 cache_read_per_mtok: 0.50,
119 };
120
121 pub const OPUS_3: ModelPricing = ModelPricing {
122 name: "Claude Opus 3/4/4.1",
123 input_per_mtok: 15.0,
124 output_per_mtok: 75.0,
125 cache_write_per_mtok: 18.75,
126 cache_read_per_mtok: 1.50,
127 };
128
129 pub const HAIKU_4_5: ModelPricing = ModelPricing {
130 name: "Claude Haiku 4.5",
131 input_per_mtok: 1.0,
132 output_per_mtok: 5.0,
133 cache_write_per_mtok: 1.25,
134 cache_read_per_mtok: 0.10,
135 };
136
137 pub const HAIKU_3_5: ModelPricing = ModelPricing {
138 name: "Claude Haiku 3.5",
139 input_per_mtok: 0.80,
140 output_per_mtok: 4.0,
141 cache_write_per_mtok: 1.0,
142 cache_read_per_mtok: 0.08,
143 };
144
145 pub const HAIKU_3: ModelPricing = ModelPricing {
146 name: "Claude Haiku 3",
147 input_per_mtok: 0.25,
148 output_per_mtok: 1.25,
149 cache_write_per_mtok: 0.30,
150 cache_read_per_mtok: 0.03,
151 };
152
153 pub fn from_model_str(model: &str) -> Option<&'static ModelPricing> {
160 let m = model.to_lowercase();
161 if m.contains("opus") {
162 if m.contains("4-5") || m.contains("4.5") || m.contains("4-6") || m.contains("4.6") {
163 Some(&Self::OPUS_4_5)
164 } else {
165 Some(&Self::OPUS_3)
166 }
167 } else if m.contains("sonnet") {
168 if m.contains("4-5") || m.contains("4.5") || m.contains("4-6") || m.contains("4.6") {
169 Some(&Self::SONNET_4_5)
170 } else if m.contains("3-7") || m.contains("3.7") {
171 Some(&Self::SONNET_3_7)
172 } else if m.contains("3-5") || m.contains("3.5") {
173 Some(&Self::SONNET_3_5)
174 } else if m.contains("-3") || m.ends_with("3") {
175 Some(&Self::SONNET_3)
176 } else {
177 Some(&Self::SONNET_4_5)
179 }
180 } else if m.contains("haiku") {
181 if m.contains("4") {
182 Some(&Self::HAIKU_4_5)
183 } else if m.contains("3-5") || m.contains("3.5") {
184 Some(&Self::HAIKU_3_5)
185 } else {
186 Some(&Self::HAIKU_3)
187 }
188 } else {
189 None
190 }
191 }
192
193 pub fn calculate_turn_cost(&self, usage: &normalize_chat_sessions::TokenUsage) -> f64 {
195 let input_cost = (usage.input as f64 / 1_000_000.0) * self.input_per_mtok;
196 let output_cost = (usage.output as f64 / 1_000_000.0) * self.output_per_mtok;
197 let cache_write_cost =
198 (usage.cache_create.unwrap_or(0) as f64 / 1_000_000.0) * self.cache_write_per_mtok;
199 let cache_read_cost =
200 (usage.cache_read.unwrap_or(0) as f64 / 1_000_000.0) * self.cache_read_per_mtok;
201 input_cost + output_cost + cache_write_cost + cache_read_cost
202 }
203
204 pub fn calculate_cost(&self, stats: &TokenStats) -> CostBreakdown {
206 let input_cost = (stats.total_input as f64 / 1_000_000.0) * self.input_per_mtok;
207 let output_cost = (stats.total_output as f64 / 1_000_000.0) * self.output_per_mtok;
208 let cache_write_cost =
209 (stats.cache_create as f64 / 1_000_000.0) * self.cache_write_per_mtok;
210 let cache_read_cost = (stats.cache_read as f64 / 1_000_000.0) * self.cache_read_per_mtok;
211
212 let without_cache_input = stats.total_input + stats.cache_read;
214 let without_cache_cost = (without_cache_input as f64 / 1_000_000.0) * self.input_per_mtok;
215 let with_cache_cost = input_cost + cache_read_cost;
216 let cache_savings = without_cache_cost - with_cache_cost;
217
218 CostBreakdown {
219 model: self.name,
220 input_cost,
221 output_cost,
222 cache_write_cost,
223 cache_read_cost,
224 total_cost: input_cost + output_cost + cache_write_cost + cache_read_cost,
225 cache_savings,
226 }
227 }
228}
229
230#[derive(Debug, Clone, Serialize, schemars::JsonSchema, Deserialize)]
232pub struct CostBreakdown {
233 pub model: &'static str,
234 pub input_cost: f64,
235 pub output_cost: f64,
236 pub cache_write_cost: f64,
237 pub cache_read_cost: f64,
238 pub total_cost: f64,
239 pub cache_savings: f64,
240}
241
242impl TokenStats {
243 pub fn avg_context(&self) -> u64 {
244 if self.api_calls == 0 {
245 0
246 } else {
247 (self.total_input + self.cache_read) / self.api_calls as u64
248 }
249 }
250
251 pub fn update_context(&mut self, context_size: u64) {
252 if self.min_context == 0 || context_size < self.min_context {
253 self.min_context = context_size;
254 }
255 if context_size > self.max_context {
256 self.max_context = context_size;
257 }
258 }
259}
260
261#[derive(Debug, Clone, Serialize, schemars::JsonSchema, Deserialize)]
263pub struct ErrorPattern {
264 pub category: String,
265 pub count: usize,
266 pub examples: Vec<String>,
267}
268
269impl ErrorPattern {
270 pub fn new(category: impl Into<String>) -> Self {
271 Self {
272 category: category.into(),
273 count: 0,
274 examples: Vec::new(),
275 }
276 }
277}
278
279#[derive(Debug, Clone, Serialize, schemars::JsonSchema, Deserialize)]
281pub struct ToolChain {
282 pub tools: Vec<String>,
283 pub turn_range: (usize, usize),
284}
285
286impl ToolChain {
287 pub fn len(&self) -> usize {
288 self.tools.len()
289 }
290
291 pub fn is_empty(&self) -> bool {
292 self.tools.is_empty()
293 }
294
295 pub fn potential_savings(&self) -> usize {
297 if self.len() <= 1 { 0 } else { self.len() - 1 }
298 }
299
300 pub fn is_safe_parallel(&self) -> bool {
302 self.tools.iter().all(|tool| {
303 matches!(
304 tool.as_str(),
305 "Read" | "Glob" | "Grep" | "Bash" | "Task" | "WebFetch" | "WebSearch"
306 )
307 })
308 }
309}
310
311#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema, Deserialize)]
313#[serde(rename_all = "snake_case")]
314pub enum CorrectionKind {
315 Apology,
316 Mistake,
317 LetMeFix,
318 Actually,
319}
320
321impl CorrectionKind {
322 pub fn as_str(&self) -> &'static str {
323 match self {
324 CorrectionKind::Apology => "Apology",
325 CorrectionKind::Mistake => "Mistake",
326 CorrectionKind::LetMeFix => "Let me fix",
327 CorrectionKind::Actually => "Actually",
328 }
329 }
330}
331
332#[derive(Debug, Clone, Serialize, schemars::JsonSchema, Deserialize)]
334pub struct Correction {
335 pub turn: usize,
336 pub text: String,
337 pub category: CorrectionKind,
338}
339
340#[derive(Debug, Clone, Default, Serialize, schemars::JsonSchema, Deserialize)]
342pub struct FileOperation {
343 pub path: String,
344 pub reads: usize,
345 pub edits: usize,
346 pub writes: usize,
347}
348
349impl FileOperation {
350 pub fn total(&self) -> usize {
351 self.reads + self.edits + self.writes
352 }
353}
354
355#[derive(Debug, Clone, Default, Serialize, schemars::JsonSchema, Deserialize)]
357pub struct CommandStats {
358 pub category: String,
359 pub commands: Vec<CommandDetail>,
360 pub total_calls: usize,
361 pub total_errors: usize,
362 pub output_tokens: u64,
364}
365
366#[derive(Debug, Clone, Default, Serialize, schemars::JsonSchema, Deserialize)]
368pub struct CommandDetail {
369 pub pattern: String,
370 pub calls: usize,
371 pub errors: usize,
372}
373
374#[derive(Debug, Clone, Default, Serialize, schemars::JsonSchema, Deserialize)]
376pub struct RetryHotspot {
377 pub pattern: String,
378 pub attempts: usize,
379 pub failures: usize,
380 pub output_tokens: u64,
381 pub turn_indices: Vec<usize>,
382}
383
384#[derive(Debug, Clone, Serialize, schemars::JsonSchema, Deserialize)]
386pub struct ToolPattern {
387 pub tools: Vec<String>,
388 pub occurrences: usize,
389}
390
391impl ToolPattern {
392 pub fn pattern_str(&self) -> String {
393 self.tools.join(" → ")
394 }
395}
396
397#[derive(Debug, Clone, Default, Serialize, schemars::JsonSchema, Deserialize)]
399pub struct DedupTokenStats {
400 pub unique_input: u64,
402 pub unique_output: u64,
404 pub total_billed: u64,
406 pub uniqueness_ratio: f64,
408}
409
410#[derive(Debug, Clone, Default, Serialize, schemars::JsonSchema, Deserialize)]
412pub struct SessionAnalysisReport {
413 pub session_path: PathBuf,
414 pub format: String,
415 pub message_counts: HashMap<String, usize>,
416 pub tool_stats: HashMap<String, ToolStats>,
417 pub token_stats: TokenStats,
418 pub error_patterns: Vec<ErrorPattern>,
419 pub file_tokens: HashMap<String, u64>,
421 pub parallel_opportunities: usize,
423 pub total_turns: usize,
424 pub tool_chains: Vec<ToolChain>,
426 pub corrections: Vec<Correction>,
428 pub context_per_turn: Vec<u64>,
430 pub file_operations: HashMap<String, FileOperation>,
432 pub tool_patterns: Vec<ToolPattern>,
434 pub command_stats: Vec<CommandStats>,
436 pub retry_hotspots: Vec<RetryHotspot>,
438 pub actual_cost: Option<f64>,
440 pub dedup_tokens: Option<DedupTokenStats>,
442 pub largest_tool_results: Vec<LargestToolResult>,
444 #[serde(skip)]
448 #[schemars(skip)]
449 pub tool_sort: Option<String>,
450}
451
452impl SessionAnalysisReport {
453 pub fn new(session_path: PathBuf, format: impl Into<String>) -> Self {
454 Self {
455 session_path,
456 format: format.into(),
457 ..Default::default()
458 }
459 }
460
461 pub fn total_tool_calls(&self) -> usize {
462 self.tool_stats.values().map(|t| t.calls).sum()
463 }
464
465 pub fn total_errors(&self) -> usize {
466 self.tool_stats.values().map(|t| t.errors).sum()
467 }
468
469 pub fn overall_success_rate(&self) -> f64 {
470 let total = self.total_tool_calls();
471 if total == 0 {
472 0.0
473 } else {
474 (total - self.total_errors()) as f64 / total as f64
475 }
476 }
477
478 pub fn format_text(&self) -> String {
480 let mut lines = vec![
481 "# Session Analysis".to_string(),
482 String::new(),
483 "## Summary".to_string(),
484 String::new(),
485 format!("- **Format**: {}", self.format),
486 format!("- **Tool calls**: {}", self.total_tool_calls()),
487 format!(
488 "- **Success rate**: {:.1}%",
489 self.overall_success_rate() * 100.0
490 ),
491 format!("- **Total turns**: {}", self.total_turns),
492 format!(
493 "- **Parallel opportunities**: {}",
494 self.parallel_opportunities
495 ),
496 String::new(),
497 ];
498
499 if !self.message_counts.is_empty() {
501 lines.push("## Message Types".to_string());
502 lines.push(String::new());
503 lines.push("| Type | Count |".to_string());
504 lines.push("|------|-------|".to_string());
505 let mut counts: Vec<_> = self.message_counts.iter().collect();
506 counts.sort_by(|a, b| b.1.cmp(a.1));
507 for (msg_type, count) in counts {
508 lines.push(format!("| {} | {} |", msg_type, count));
509 }
510 lines.push(String::new());
511 }
512
513 if !self.tool_stats.is_empty() {
515 lines.push("## Tool Usage".to_string());
516 lines.push(String::new());
517 lines.push("| Tool | Calls | Errors | Success Rate |".to_string());
518 lines.push("|------|-------|--------|--------------|".to_string());
519 let mut tools: Vec<_> = self.tool_stats.values().collect();
520 sort_tool_stats_by_hint(&mut tools, self.tool_sort.as_deref());
521 for tool in tools {
522 lines.push(format!(
523 "| {} | {} | {} | {:.0}% |",
524 tool.name,
525 tool.calls,
526 tool.errors,
527 tool.success_rate() * 100.0
528 ));
529 }
530 lines.push(String::new());
531 }
532
533 if !self.largest_tool_results.is_empty() {
535 lines.push("## Largest Tool Results".to_string());
536 lines.push(String::new());
537 lines.push("| Tool | Chars | Turn | Preview |".to_string());
538 lines.push("|------|-------|------|---------|".to_string());
539 for r in &self.largest_tool_results {
540 lines.push(format!(
541 "| {} | {} | {} | {} |",
542 r.tool_name, r.chars, r.turn, r.preview
543 ));
544 }
545 lines.push(String::new());
546 }
547
548 if self.token_stats.api_calls > 0 {
550 let ts = &self.token_stats;
551 lines.push("## Token Usage".to_string());
552 lines.push(String::new());
553 lines.push(format!("- **API calls**: {}", ts.api_calls));
554 lines.push(format!("- **Input tokens**: {}", ts.total_input));
555 lines.push(format!("- **Output tokens**: {}", ts.total_output));
556 lines.push(format!(
557 "- **Total tokens**: {}",
558 ts.total_input + ts.total_output
559 ));
560 if ts.cache_read > 0 {
561 lines.push(format!("- **Cache read**: {} tokens", ts.cache_read));
562 }
563 if ts.cache_create > 0 {
564 lines.push(format!("- **Cache create**: {} tokens", ts.cache_create));
565 }
566 lines.push(format!("- **Avg context**: {} tokens", ts.avg_context()));
567 lines.push(format!(
568 "- **Context range**: {} - {}",
569 ts.min_context, ts.max_context
570 ));
571 lines.push(String::new());
572
573 lines.push("## Cost Estimate".to_string());
575 lines.push(String::new());
576
577 if let Some(actual) = self.actual_cost {
578 lines.push(format!("**Actual cost**: ${:.2}", actual));
579 lines.push(String::new());
580
581 let sonnet = ModelPricing::SONNET_4_5.calculate_cost(ts);
582 let opus = ModelPricing::OPUS_4_5.calculate_cost(ts);
583 let haiku = ModelPricing::HAIKU_4_5.calculate_cost(ts);
584 lines.push("**What-if pricing:**".to_string());
585 lines.push(format!(" - {}: ${:.2}", sonnet.model, sonnet.total_cost));
586 lines.push(format!(" - {}: ${:.2}", opus.model, opus.total_cost));
587 lines.push(format!(" - {}: ${:.2}", haiku.model, haiku.total_cost));
588 } else {
589 let sonnet = ModelPricing::SONNET_4_5.calculate_cost(ts);
590 lines.push(format!(
591 "**{} (default)**: ${:.2}",
592 sonnet.model, sonnet.total_cost
593 ));
594 lines.push(format!(" - Input: ${:.2}", sonnet.input_cost));
595 lines.push(format!(" - Output: ${:.2}", sonnet.output_cost));
596 if sonnet.cache_write_cost > 0.0 {
597 lines.push(format!(" - Cache write: ${:.2}", sonnet.cache_write_cost));
598 }
599 if sonnet.cache_read_cost > 0.0 {
600 lines.push(format!(" - Cache read: ${:.2}", sonnet.cache_read_cost));
601 }
602 if sonnet.cache_savings > 0.0 {
603 let savings_pct =
604 (sonnet.cache_savings / (sonnet.total_cost + sonnet.cache_savings)) * 100.0;
605 lines.push(format!(
606 " - Cache savings: ${:.2} ({:.1}%)",
607 sonnet.cache_savings, savings_pct
608 ));
609 }
610 lines.push(String::new());
611
612 let opus = ModelPricing::OPUS_4_5.calculate_cost(ts);
613 let haiku = ModelPricing::HAIKU_4_5.calculate_cost(ts);
614 lines.push("**Alternative models:**".to_string());
615 lines.push(format!(
616 " - {}: ${:.2} ({:.1}x)",
617 opus.model,
618 opus.total_cost,
619 opus.total_cost / sonnet.total_cost
620 ));
621 lines.push(format!(
622 " - {}: ${:.2} ({:.1}x)",
623 haiku.model,
624 haiku.total_cost,
625 haiku.total_cost / sonnet.total_cost
626 ));
627 }
628 lines.push(String::new());
629
630 if let Some(dedup) = &self.dedup_tokens {
632 lines.push("## Token Efficiency".to_string());
633 lines.push(String::new());
634 lines.push(format!(
635 "- **Unique input**: {}",
636 format_tokens(dedup.unique_input)
637 ));
638 lines.push(format!(
639 "- **Unique output**: {}",
640 format_tokens(dedup.unique_output)
641 ));
642 lines.push(format!(
643 "- **Uniqueness ratio**: {:.1}%",
644 dedup.uniqueness_ratio * 100.0
645 ));
646 let redundant = dedup
647 .total_billed
648 .saturating_sub(dedup.unique_input + dedup.unique_output);
649 lines.push(format!(
650 "- **Redundant context**: {}",
651 format_tokens(redundant)
652 ));
653 lines.push(String::new());
654 }
655
656 if !self.context_per_turn.is_empty() && self.context_per_turn.iter().any(|&c| c > 0) {
658 lines.push("## Context Growth".to_string());
659 lines.push(String::new());
660
661 let intervals = if self.context_per_turn.len() <= 10 {
663 (0..self.context_per_turn.len()).collect::<Vec<_>>()
664 } else {
665 let step = self.context_per_turn.len() / 10;
666 (0..10)
667 .map(|i| i * step)
668 .chain(std::iter::once(self.context_per_turn.len() - 1))
669 .collect()
670 };
671
672 for idx in intervals {
673 if idx < self.context_per_turn.len() {
674 let context = self.context_per_turn[idx];
675 if context > 0 {
676 let warning = if context >= 100_000 {
677 " ⚠️ APPROACHING LIMIT"
678 } else if context >= 80_000 {
679 " ⚠️ High"
680 } else {
681 ""
682 };
683 lines.push(format!(
684 "- Turn {}: {}{}",
685 idx,
686 format_tokens(context),
687 warning
688 ));
689 }
690 }
691 }
692 lines.push(String::new());
693 }
694 }
695
696 if !self.command_stats.is_empty() {
698 lines.push("## Command Breakdown".to_string());
699 lines.push(String::new());
700 lines.push("| Category | Calls | Errors | ~Output Tokens |".to_string());
701 lines.push("|----------|-------|--------|----------------|".to_string());
702 for stat in &self.command_stats {
703 lines.push(format!(
704 "| {} | {} | {} | {} |",
705 stat.category,
706 stat.total_calls,
707 stat.total_errors,
708 format_tokens(stat.output_tokens)
709 ));
710 }
711 lines.push(String::new());
712
713 let mut all_commands: Vec<&CommandDetail> = self
715 .command_stats
716 .iter()
717 .flat_map(|s| &s.commands)
718 .collect();
719 all_commands.sort_by(|a, b| b.calls.cmp(&a.calls));
720 if !all_commands.is_empty() {
721 lines.push("Top commands:".to_string());
722 for cmd in all_commands.iter().take(10) {
723 if cmd.errors > 0 {
724 lines.push(format!(
725 "- {}: {} calls ({} errors)",
726 cmd.pattern, cmd.calls, cmd.errors
727 ));
728 } else {
729 lines.push(format!("- {}: {} calls", cmd.pattern, cmd.calls));
730 }
731 }
732 lines.push(String::new());
733 }
734 }
735
736 if !self.retry_hotspots.is_empty() {
738 lines.push("## Retry Hotspots".to_string());
739 lines.push(String::new());
740 for hotspot in &self.retry_hotspots {
741 lines.push(format!(
742 "- **{}** — {} failures / {} attempts, ~{} output tokens",
743 hotspot.pattern,
744 hotspot.failures,
745 hotspot.attempts,
746 format_tokens(hotspot.output_tokens)
747 ));
748 }
749 lines.push(String::new());
750 }
751
752 if !self.file_tokens.is_empty() {
754 lines.push("## Token Hotspots".to_string());
755 lines.push(String::new());
756 lines.push("| Path | Tokens |".to_string());
757 lines.push("|------|--------|".to_string());
758 let mut paths: Vec<_> = self.file_tokens.iter().collect();
759 paths.sort_by(|a, b| b.1.cmp(a.1));
760 for (path, tokens) in paths.iter().take(10) {
761 lines.push(format!("| {} | {} |", path, tokens));
762 }
763 lines.push(String::new());
764 }
765
766 if !self.file_operations.is_empty() {
768 lines.push("## File Operations".to_string());
769 lines.push(String::new());
770 let mut ops: Vec<_> = self.file_operations.values().collect();
771 ops.sort_by_key(|b| std::cmp::Reverse(b.total()));
772 lines.push("| File | Reads | Edits | Writes | Total |".to_string());
773 lines.push("|------|-------|-------|--------|-------|".to_string());
774 for op in ops.iter().take(20) {
775 lines.push(format!(
776 "| {} | {} | {} | {} | {} |",
777 op.path,
778 op.reads,
779 op.edits,
780 op.writes,
781 op.total()
782 ));
783 }
784 lines.push(String::new());
785 }
786
787 if !self.tool_chains.is_empty() {
789 let mut sorted_chains = self.tool_chains.clone();
790 sorted_chains.sort_by_key(|b| std::cmp::Reverse(b.potential_savings()));
791
792 let top_opportunities: Vec<_> = sorted_chains
793 .iter()
794 .filter(|c| c.potential_savings() >= 2)
795 .take(5)
796 .collect();
797
798 if !top_opportunities.is_empty() {
799 lines.push("## Parallelization Opportunities".to_string());
800 lines.push(String::new());
801
802 let total_savings: usize =
803 self.tool_chains.iter().map(|c| c.potential_savings()).sum();
804 lines.push(format!(
805 "**Estimated savings**: {} API calls could be reduced by running tools in parallel",
806 total_savings
807 ));
808 lines.push(String::new());
809
810 for chain in &top_opportunities {
811 let tools_str = chain.tools.join(" → ");
812 let safe_marker = if chain.is_safe_parallel() {
813 " ✓ Safe"
814 } else {
815 ""
816 };
817 lines.push(format!(
818 "- **Turns {}-{}**: {} API calls → 1 call (save {}){}",
819 chain.turn_range.0,
820 chain.turn_range.1,
821 chain.len(),
822 chain.potential_savings(),
823 safe_marker
824 ));
825 lines.push(format!(" Tools: {}", tools_str));
826 }
827 lines.push(String::new());
828 }
829 }
830
831 if !self.tool_patterns.is_empty() {
833 lines.push("## Common Tool Patterns".to_string());
834 lines.push(String::new());
835 lines.push("Frequent sequences across all sessions:".to_string());
836 lines.push(String::new());
837 for pattern in self.tool_patterns.iter().take(10) {
838 lines.push(format!(
839 "- **{}×**: {}",
840 pattern.occurrences,
841 pattern.pattern_str()
842 ));
843 }
844 lines.push(String::new());
845 }
846
847 if !self.tool_chains.is_empty() {
849 lines.push("## Tool Chains".to_string());
850 lines.push(String::new());
851 lines.push(
852 "Sequences of consecutive single-tool calls (potential parallelization):"
853 .to_string(),
854 );
855 lines.push(String::new());
856 for chain in &self.tool_chains {
857 let tools_str = chain.tools.join(" → ");
858 lines.push(format!(
859 "- **Turns {}-{}** ({} tools): {}",
860 chain.turn_range.0,
861 chain.turn_range.1,
862 chain.len(),
863 tools_str
864 ));
865 }
866 lines.push(String::new());
867 }
868
869 if !self.corrections.is_empty() {
871 lines.push("## Corrections & Apologies".to_string());
872 lines.push(String::new());
873 for correction in &self.corrections {
874 lines.push(format!(
875 "- **Turn {}** [{}]: {}",
876 correction.turn,
877 correction.category.as_str(),
878 correction.text
879 ));
880 }
881 lines.push(String::new());
882 }
883
884 if !self.error_patterns.is_empty() {
886 lines.push("## Error Patterns".to_string());
887 lines.push(String::new());
888 for pattern in &self.error_patterns {
889 lines.push(format!("### {} ({})", pattern.category, pattern.count));
890 for ex in &pattern.examples {
891 lines.push(format!("- {}", ex));
892 }
893 lines.push(String::new());
894 }
895 }
896
897 lines.join("\n")
898 }
899
900 pub fn format_pretty(&self) -> String {
902 let mut out = String::new();
903 self.write_pretty(&mut out).unwrap_or_default();
905 out
906 }
907
908 fn write_pretty(&self, out: &mut String) -> std::fmt::Result {
909 use std::fmt::Write;
910
911 writeln!(out, "\x1b[1;36m━━━ Session Analysis ━━━\x1b[0m")?;
913 writeln!(out)?;
914
915 writeln!(out, "\x1b[1mFormat:\x1b[0m {}", self.format)?;
917 writeln!(
918 out,
919 "\x1b[1mTool calls:\x1b[0m {} ({:.1}% success)",
920 self.total_tool_calls(),
921 self.overall_success_rate() * 100.0
922 )?;
923 writeln!(out, "\x1b[1mTurns:\x1b[0m {}", self.total_turns)?;
924 if self.parallel_opportunities > 0 {
925 writeln!(
926 out,
927 "\x1b[1mParallel opportunities:\x1b[0m {}",
928 self.parallel_opportunities
929 )?;
930 }
931 writeln!(out)?;
932
933 if !self.tool_stats.is_empty() {
935 writeln!(out, "\x1b[1;36m━━━ Tool Usage ━━━\x1b[0m")?;
936
937 let mut tools: Vec<_> = self.tool_stats.values().collect();
938 sort_tool_stats_by_hint(&mut tools, self.tool_sort.as_deref());
939
940 let max_calls = tools.first().map(|t| t.calls).unwrap_or(1);
941 let max_name_len = tools.iter().map(|t| t.name.len()).max().unwrap_or(10);
942
943 for tool in tools {
944 let bar_width = 30;
945 let filled = (tool.calls as f64 / max_calls as f64 * bar_width as f64) as usize;
946 let bar: String = "█".repeat(filled) + &"░".repeat(bar_width - filled);
947
948 let color = if tool.errors > 0 {
949 "\x1b[31m"
950 } else {
951 "\x1b[32m"
952 };
953 writeln!(
954 out,
955 "{:>width$} {} {}{:>5}\x1b[0m{}",
956 tool.name,
957 bar,
958 color,
959 tool.calls,
960 if tool.errors > 0 {
961 format!(" ({} errors)", tool.errors)
962 } else {
963 String::new()
964 },
965 width = max_name_len
966 )?;
967 }
968 writeln!(out)?;
969 }
970
971 if !self.largest_tool_results.is_empty() {
973 writeln!(out, "\x1b[1;36m━━━ Largest Tool Results ━━━\x1b[0m")?;
974 for r in &self.largest_tool_results {
975 writeln!(
976 out,
977 "\x1b[33m{:>8}\x1b[0m chars turn {:>4} \x1b[36m{}\x1b[0m {}",
978 r.chars,
979 r.turn,
980 r.tool_name,
981 r.preview.chars().take(60).collect::<String>()
982 )?;
983 }
984 writeln!(out)?;
985 }
986
987 if self.token_stats.api_calls > 0 {
989 let ts = &self.token_stats;
990 writeln!(out, "\x1b[1;36m━━━ Token Usage ━━━\x1b[0m")?;
991 writeln!(out, "API calls: {}", ts.api_calls)?;
992 writeln!(out, "Avg context: {} tokens", ts.avg_context())?;
993 writeln!(
994 out,
995 "Context range: {} - {}",
996 ts.min_context, ts.max_context
997 )?;
998 if ts.cache_read > 0 {
999 writeln!(out, "Cache read: {} tokens", format_tokens(ts.cache_read))?;
1000 }
1001 if ts.cache_create > 0 {
1002 writeln!(
1003 out,
1004 "Cache create: {} tokens",
1005 format_tokens(ts.cache_create)
1006 )?;
1007 }
1008 writeln!(out)?;
1009
1010 writeln!(out, "\x1b[1;36m━━━ Cost Estimate ━━━\x1b[0m")?;
1012
1013 if let Some(actual) = self.actual_cost {
1014 writeln!(
1015 out,
1016 "\x1b[1mActual cost:\x1b[0m \x1b[32m${:.2}\x1b[0m",
1017 actual
1018 )?;
1019
1020 let sonnet = ModelPricing::SONNET_4_5.calculate_cost(ts);
1021 let opus = ModelPricing::OPUS_4_5.calculate_cost(ts);
1022 let haiku = ModelPricing::HAIKU_4_5.calculate_cost(ts);
1023 writeln!(
1024 out,
1025 "What-if: {} ${:.2} | {} ${:.2} | {} ${:.2}",
1026 sonnet.model,
1027 sonnet.total_cost,
1028 opus.model,
1029 opus.total_cost,
1030 haiku.model,
1031 haiku.total_cost
1032 )?;
1033 } else {
1034 let sonnet = ModelPricing::SONNET_4_5.calculate_cost(ts);
1035 writeln!(
1036 out,
1037 "\x1b[1m{}\x1b[0m: \x1b[32m${:.2}\x1b[0m",
1038 sonnet.model, sonnet.total_cost
1039 )?;
1040 if sonnet.cache_savings > 0.0 {
1041 let savings_pct =
1042 (sonnet.cache_savings / (sonnet.total_cost + sonnet.cache_savings)) * 100.0;
1043 writeln!(
1044 out,
1045 " Cache savings: \x1b[33m${:.2}\x1b[0m ({:.1}%)",
1046 sonnet.cache_savings, savings_pct
1047 )?;
1048 }
1049 writeln!(
1050 out,
1051 " Input: ${:.2} | Output: ${:.2}",
1052 sonnet.input_cost, sonnet.output_cost
1053 )?;
1054
1055 let opus = ModelPricing::OPUS_4_5.calculate_cost(ts);
1056 let haiku = ModelPricing::HAIKU_4_5.calculate_cost(ts);
1057 writeln!(
1058 out,
1059 "If {}: ${:.2} (\x1b[31m{:.1}x\x1b[0m) | If {}: ${:.2} (\x1b[32m{:.1}x\x1b[0m)",
1060 opus.model,
1061 opus.total_cost,
1062 opus.total_cost / sonnet.total_cost,
1063 haiku.model,
1064 haiku.total_cost,
1065 haiku.total_cost / sonnet.total_cost
1066 )?;
1067 }
1068
1069 if let Some(dedup) = &self.dedup_tokens {
1071 writeln!(out)?;
1072 writeln!(out, "\x1b[1;36m━━━ Token Efficiency ━━━\x1b[0m")?;
1073 writeln!(
1074 out,
1075 "Unique input: {} | Unique output: {}",
1076 format_tokens(dedup.unique_input),
1077 format_tokens(dedup.unique_output)
1078 )?;
1079 writeln!(
1080 out,
1081 "Uniqueness: \x1b[33m{:.1}%\x1b[0m",
1082 dedup.uniqueness_ratio * 100.0
1083 )?;
1084 let redundant = dedup
1085 .total_billed
1086 .saturating_sub(dedup.unique_input + dedup.unique_output);
1087 writeln!(out, "Redundant context: {}", format_tokens(redundant))?;
1088 }
1089 writeln!(out)?;
1090
1091 if !self.context_per_turn.is_empty() && self.context_per_turn.iter().any(|&c| c > 0) {
1093 writeln!(out, "\x1b[1;36m━━━ Context Growth ━━━\x1b[0m")?;
1094 for line in token_growth_chart(&self.context_per_turn, 20) {
1095 writeln!(out, "{}", line)?;
1096 }
1097 writeln!(out)?;
1098 }
1099 }
1100
1101 if !self.command_stats.is_empty() {
1103 writeln!(out, "\x1b[1;36m━━━ Command Breakdown ━━━\x1b[0m")?;
1104
1105 let max_calls = self
1106 .command_stats
1107 .first()
1108 .map(|s| s.total_calls)
1109 .unwrap_or(1);
1110 let max_cat_len = self
1111 .command_stats
1112 .iter()
1113 .map(|s| s.category.len())
1114 .max()
1115 .unwrap_or(8);
1116
1117 for stat in &self.command_stats {
1118 let bar_width = 20;
1119 let filled =
1120 (stat.total_calls as f64 / max_calls as f64 * bar_width as f64) as usize;
1121 let bar: String = "█".repeat(filled) + &"░".repeat(bar_width - filled);
1122
1123 let error_str = if stat.total_errors > 0 {
1124 format!(
1125 " (\x1b[31m{} error{}\x1b[0m)",
1126 stat.total_errors,
1127 if stat.total_errors == 1 { "" } else { "s" }
1128 )
1129 } else {
1130 String::new()
1131 };
1132
1133 writeln!(
1134 out,
1135 "{:>width$} {} {:>3} calls{} ~{}",
1136 stat.category,
1137 bar,
1138 stat.total_calls,
1139 error_str,
1140 format_tokens(stat.output_tokens),
1141 width = max_cat_len
1142 )?;
1143 }
1144 writeln!(out)?;
1145 }
1146
1147 if !self.retry_hotspots.is_empty() {
1149 writeln!(out, "\x1b[1;36m━━━ Retry Hotspots ━━━\x1b[0m")?;
1150 for hotspot in &self.retry_hotspots {
1151 writeln!(
1152 out,
1153 "\x1b[33m⚠\x1b[0m {} — {}/{} failed, ~{} output tokens burned",
1154 hotspot.pattern,
1155 hotspot.failures,
1156 hotspot.attempts,
1157 format_tokens(hotspot.output_tokens)
1158 )?;
1159 }
1160 writeln!(out)?;
1161 }
1162
1163 if !self.file_operations.is_empty() {
1165 writeln!(out, "\x1b[1;36m━━━ File Operations ━━━\x1b[0m")?;
1166 let mut ops: Vec<_> = self.file_operations.values().collect();
1167 ops.sort_by_key(|b| std::cmp::Reverse(b.total()));
1168
1169 for op in ops.iter().take(15) {
1170 let bar_width = 20;
1171 let max_total = ops.first().map(|o| o.total()).unwrap_or(1);
1172 let filled = (op.total() as f64 / max_total as f64 * bar_width as f64) as usize;
1173 let bar: String = "█".repeat(filled) + &"░".repeat(bar_width - filled);
1174
1175 let mut parts = Vec::new();
1177 if op.reads > 0 {
1178 parts.push(format!(
1179 "\x1b[36m{} read{}\x1b[0m",
1180 op.reads,
1181 if op.reads == 1 { "" } else { "s" }
1182 ));
1183 }
1184 if op.edits > 0 {
1185 parts.push(format!(
1186 "\x1b[33m{} edit{}\x1b[0m",
1187 op.edits,
1188 if op.edits == 1 { "" } else { "s" }
1189 ));
1190 }
1191 if op.writes > 0 {
1192 parts.push(format!(
1193 "\x1b[32m{} write{}\x1b[0m",
1194 op.writes,
1195 if op.writes == 1 { "" } else { "s" }
1196 ));
1197 }
1198 let ops_str = parts.join(", ");
1199 writeln!(out, "{} {} {}", bar, ops_str, op.path)?;
1200 }
1201 writeln!(out)?;
1202 }
1203
1204 if !self.file_tokens.is_empty() {
1206 writeln!(out, "\x1b[1;36m━━━ Token Hotspots ━━━\x1b[0m")?;
1207 let mut paths: Vec<_> = self.file_tokens.iter().collect();
1208 paths.sort_by(|a, b| b.1.cmp(a.1));
1209
1210 let max_tokens = paths.first().map(|(_, t)| **t).unwrap_or(1);
1211
1212 for (path, tokens) in paths.iter().take(10) {
1213 let bar_width = 20;
1214 let filled = (**tokens as f64 / max_tokens as f64 * bar_width as f64) as usize;
1215 let bar: String = "█".repeat(filled) + &"░".repeat(bar_width - filled);
1216 writeln!(out, "{} {:>8} {}", bar, format_tokens(**tokens), path)?;
1217 }
1218 writeln!(out)?;
1219 }
1220
1221 if !self.message_counts.is_empty() {
1223 writeln!(out, "\x1b[1;36m━━━ Message Types ━━━\x1b[0m")?;
1224 let mut counts: Vec<_> = self.message_counts.iter().collect();
1225 counts.sort_by(|a, b| b.1.cmp(a.1));
1226
1227 let items: Vec<String> = counts
1228 .iter()
1229 .take(8)
1230 .map(|(k, v)| format!("{}:{}", k, v))
1231 .collect();
1232 writeln!(out, "{}", items.join(" "))?;
1233 }
1234
1235 if !self.tool_chains.is_empty() {
1237 let mut sorted_chains = self.tool_chains.clone();
1238 sorted_chains.sort_by_key(|b| std::cmp::Reverse(b.potential_savings()));
1239
1240 let top_opportunities: Vec<_> = sorted_chains
1241 .iter()
1242 .filter(|c| c.potential_savings() >= 2)
1243 .take(5)
1244 .collect();
1245
1246 if !top_opportunities.is_empty() {
1247 writeln!(out)?;
1248 writeln!(out, "\x1b[1;36m━━━ Parallelization Hints ━━━\x1b[0m")?;
1249
1250 let total_savings: usize =
1251 self.tool_chains.iter().map(|c| c.potential_savings()).sum();
1252 writeln!(
1253 out,
1254 "Potential savings: \x1b[33m{} API calls\x1b[0m",
1255 total_savings
1256 )?;
1257
1258 for chain in &top_opportunities {
1259 let safe_marker = if chain.is_safe_parallel() {
1260 "\x1b[32m✓\x1b[0m"
1261 } else {
1262 "\x1b[33m⚠\x1b[0m"
1263 };
1264 writeln!(
1265 out,
1266 "{} Turns {}-{}: \x1b[33m{} → 1\x1b[0m (save {})",
1267 safe_marker,
1268 chain.turn_range.0,
1269 chain.turn_range.1,
1270 chain.len(),
1271 chain.potential_savings()
1272 )?;
1273 let tools_str = chain.tools.join(" → ");
1274 writeln!(out, " {}", tools_str)?;
1275 }
1276 }
1277 }
1278
1279 if !self.tool_patterns.is_empty() {
1281 writeln!(out)?;
1282 writeln!(out, "\x1b[1;36m━━━ Common Tool Patterns ━━━\x1b[0m")?;
1283 writeln!(out, "Frequent sequences across all sessions:")?;
1284 writeln!(out)?;
1285 for pattern in self.tool_patterns.iter().take(10) {
1286 writeln!(
1287 out,
1288 "\x1b[33m{:>3}×\x1b[0m {}",
1289 pattern.occurrences,
1290 pattern.pattern_str()
1291 )?;
1292 }
1293 }
1294
1295 if !self.tool_chains.is_empty() {
1297 writeln!(out)?;
1298 writeln!(out, "\x1b[1;36m━━━ Tool Chains ━━━\x1b[0m")?;
1299 writeln!(
1300 out,
1301 "Found {} sequences of consecutive single-tool calls:",
1302 self.tool_chains.len()
1303 )?;
1304 for chain in self.tool_chains.iter().take(10) {
1305 let tools_str = chain.tools.join(" → ");
1306 writeln!(
1307 out,
1308 "\x1b[33m▸\x1b[0m Turns {}-{} ({}): {}",
1309 chain.turn_range.0,
1310 chain.turn_range.1,
1311 chain.len(),
1312 tools_str
1313 )?;
1314 }
1315 }
1316
1317 if !self.corrections.is_empty() {
1319 writeln!(out)?;
1320 writeln!(out, "\x1b[1;36m━━━ Corrections & Apologies ━━━\x1b[0m")?;
1321 for correction in &self.corrections {
1322 writeln!(
1323 out,
1324 "\x1b[31m⚠\x1b[0m Turn {} [{}]: {}",
1325 correction.turn,
1326 correction.category.as_str(),
1327 correction.text.chars().take(60).collect::<String>()
1328 )?;
1329 }
1330 }
1331
1332 Ok(())
1333 }
1334}
1335
1336impl OutputFormatter for SessionAnalysisReport {
1338 fn format_text(&self) -> String {
1339 SessionAnalysisReport::format_text(self)
1341 }
1342
1343 fn format_pretty(&self) -> String {
1344 SessionAnalysisReport::format_pretty(self)
1346 }
1347}
1348
1349impl std::fmt::Display for SessionAnalysisReport {
1350 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1351 write!(f, "{}", OutputFormatter::format_text(self))
1352 }
1353}
1354
1355fn format_tokens(tokens: u64) -> String {
1357 if tokens >= 1_000_000 {
1358 format!("{:.1}M", tokens as f64 / 1_000_000.0)
1359 } else if tokens >= 1_000 {
1360 format!("{:.1}K", tokens as f64 / 1_000.0)
1361 } else {
1362 tokens.to_string()
1363 }
1364}
1365
1366fn token_growth_chart(context_per_turn: &[u64], width: usize) -> Vec<String> {
1368 if context_per_turn.is_empty() {
1369 return vec![];
1370 }
1371
1372 let max_context = *context_per_turn.iter().max().unwrap_or(&1);
1373 let threshold_80k = 80_000;
1374 let threshold_100k = 100_000;
1375
1376 let mut lines = Vec::new();
1377
1378 let sample_rate = if context_per_turn.len() > 20 {
1380 context_per_turn.len() / 20
1381 } else {
1382 1
1383 };
1384
1385 for (idx, &context) in context_per_turn.iter().enumerate() {
1386 if context == 0 {
1387 continue; }
1389 if idx % sample_rate != 0 && idx != context_per_turn.len() - 1 {
1390 continue; }
1392
1393 let filled = ((context as f64 / max_context as f64) * width as f64) as usize;
1394 let bar = "▓".repeat(filled) + &"░".repeat(width.saturating_sub(filled));
1395
1396 let color = if context >= threshold_100k {
1398 "\x1b[31m" } else if context >= threshold_80k {
1400 "\x1b[33m" } else {
1402 "\x1b[32m" };
1404
1405 let warning = if context >= threshold_100k {
1406 " [!] APPROACHING LIMIT"
1407 } else if context >= threshold_80k {
1408 " [!] High context"
1409 } else {
1410 ""
1411 };
1412
1413 lines.push(format!(
1414 "Turn {:>3}: {}{}{}\x1b[0m {}{}",
1415 idx,
1416 color,
1417 bar,
1418 " ",
1419 format_tokens(context),
1420 warning
1421 ));
1422 }
1423
1424 lines
1425}
1426
1427pub fn categorize_error(error_text: &str) -> &'static str {
1429 let text = error_text.to_lowercase();
1430 if text.contains("exit code") {
1431 "Command failure"
1432 } else if text.contains("not found") {
1433 "File not found"
1434 } else if text.contains("permission") {
1435 "Permission error"
1436 } else if text.contains("timeout") {
1437 "Timeout"
1438 } else if text.contains("syntax") {
1439 "Syntax error"
1440 } else if text.contains("import") {
1441 "Import error"
1442 } else {
1443 "Other"
1444 }
1445}
1446
1447fn extract_file_path(tool_name: &str, input: &serde_json::Value) -> Option<String> {
1449 match tool_name {
1450 "Read" | "Write" | "Edit" => {
1451 if let Some(path) = input.get("file_path").and_then(|v| v.as_str()) {
1452 return Some(normalize_path(path));
1453 }
1454 }
1455 _ => {}
1456 }
1457 None
1458}
1459
1460pub fn detect_correction(text: &str) -> Option<(CorrectionKind, String)> {
1463 let lower = text.to_lowercase();
1464
1465 let apology_phrases = ["i apologize", "i'm sorry", "sorry about", "my apologies"];
1467 for phrase in &apology_phrases {
1468 if let Some(pos) = lower.find(phrase) {
1469 let excerpt = text.chars().skip(pos).take(80).collect();
1470 return Some((CorrectionKind::Apology, excerpt));
1471 }
1472 }
1473
1474 let mistake_phrases = [
1476 "i made a mistake",
1477 "i was wrong",
1478 "that was incorrect",
1479 "my mistake",
1480 ];
1481 for phrase in &mistake_phrases {
1482 if let Some(pos) = lower.find(phrase) {
1483 let excerpt = text.chars().skip(pos).take(80).collect();
1484 return Some((CorrectionKind::Mistake, excerpt));
1485 }
1486 }
1487
1488 let fix_phrases = ["let me fix", "i'll fix", "let me correct"];
1490 for phrase in &fix_phrases {
1491 if let Some(pos) = lower.find(phrase) {
1492 let excerpt = text.chars().skip(pos).take(80).collect();
1493 return Some((CorrectionKind::LetMeFix, excerpt));
1494 }
1495 }
1496
1497 let actually_phrases = ["actually,", "actually i", "actually that"];
1499 for phrase in &actually_phrases {
1500 if let Some(pos) = lower.find(phrase) {
1501 let excerpt = text.chars().skip(pos).take(80).collect();
1502 return Some((CorrectionKind::Actually, excerpt));
1503 }
1504 }
1505
1506 None
1507}
1508
1509pub fn normalize_path(path: &str) -> String {
1511 if !path.starts_with('/') {
1512 return path.to_string();
1513 }
1514 let parts: Vec<&str> = path.split('/').collect();
1516 for (i, part) in parts.iter().enumerate() {
1517 if matches!(
1518 *part,
1519 "src" | "lib" | "crates" | "tests" | "docs" | "packages"
1520 ) {
1521 return parts[i..].join("/");
1522 }
1523 }
1524 path.to_string()
1525}
1526
1527fn split_command_chain(cmd: &str) -> Vec<&str> {
1529 let mut parts = Vec::new();
1530 let mut start = 0;
1531 let bytes = cmd.as_bytes();
1532 let len = bytes.len();
1533 let mut i = 0;
1534 while i < len {
1535 if bytes[i] == b';' {
1536 let part = cmd[start..i].trim();
1537 if !part.is_empty() {
1538 parts.push(part);
1539 }
1540 start = i + 1;
1541 } else if i + 1 < len && bytes[i] == b'&' && bytes[i + 1] == b'&' {
1542 let part = cmd[start..i].trim();
1543 if !part.is_empty() {
1544 parts.push(part);
1545 }
1546 start = i + 2;
1547 i += 1; } else if i + 1 < len && bytes[i] == b'|' && bytes[i + 1] == b'|' {
1549 let part = cmd[start..i].trim();
1550 if !part.is_empty() {
1551 parts.push(part);
1552 }
1553 start = i + 2;
1554 i += 1;
1555 }
1556 i += 1;
1557 }
1558 let part = cmd[start..].trim();
1559 if !part.is_empty() {
1560 parts.push(part);
1561 }
1562 parts.into_iter().filter(|p| !p.starts_with('#')).collect()
1564}
1565
1566pub struct CommandCategory {
1567 pub category: &'static str,
1568 pub pattern: String,
1569}
1570
1571fn categorize_cargo(sub: &str) -> CommandCategory {
1572 let (category, pattern) = match sub {
1573 "build" | "b" => ("build", "cargo build".to_string()),
1574 "test" | "t" | "nextest" => ("test", "cargo test".to_string()),
1575 "clippy" => ("lint", "cargo clippy".to_string()),
1576 "fmt" => ("lint", "cargo fmt".to_string()),
1577 "add" | "install" => ("install", format!("cargo {}", sub)),
1578 _ => ("build", format!("cargo {}", sub)),
1579 };
1580 CommandCategory { category, pattern }
1581}
1582
1583fn categorize_npm_run(runner: &str, script: &str) -> CommandCategory {
1584 let (category, pattern) = if script.contains("build") {
1585 ("build", format!("{} run build", runner))
1586 } else if script.contains("test") {
1587 ("test", format!("{} run test", runner))
1588 } else if script.contains("lint") {
1589 ("lint", format!("{} run lint", runner))
1590 } else if script.contains("format") || script.contains("fmt") {
1591 ("lint", format!("{} run {}", runner, script))
1592 } else {
1593 ("other", format!("{} run {}", runner, script))
1594 };
1595 CommandCategory { category, pattern }
1596}
1597
1598fn categorize_js_runner(base_name: &str, sub: &str, effective: &[&str]) -> CommandCategory {
1599 match sub {
1600 "run" => {
1601 let script = effective.get(2).copied().unwrap_or("?");
1602 categorize_npm_run(base_name, script)
1603 }
1604 "build" => CommandCategory {
1605 category: "build",
1606 pattern: format!("{} build", base_name),
1607 },
1608 "test" => CommandCategory {
1609 category: "test",
1610 pattern: format!("{} test", base_name),
1611 },
1612 "install" | "i" | "add" | "ci" => CommandCategory {
1613 category: "install",
1614 pattern: format!("{} install", base_name),
1615 },
1616 _ => CommandCategory {
1617 category: "other",
1618 pattern: format!("{} {}", base_name, sub),
1619 },
1620 }
1621}
1622
1623pub fn categorize_command(cmd: &str) -> CommandCategory {
1627 let cmd = cmd.trim();
1629 let effective = cmd
1631 .split_whitespace()
1632 .skip_while(|w| w.contains('=') && !w.starts_with('-'))
1633 .collect::<Vec<_>>();
1634 if effective.is_empty() {
1635 return CommandCategory {
1636 category: "other",
1637 pattern: cmd.to_string(),
1638 };
1639 }
1640
1641 let base = effective[0];
1642 let sub = effective.get(1).copied().unwrap_or("");
1643
1644 let base_name = base.rsplit('/').next().unwrap_or(base);
1646
1647 match base_name {
1648 "cargo" => categorize_cargo(sub),
1649 "npm" | "npx" | "yarn" | "pnpm" => categorize_js_runner(base_name, sub, &effective),
1650
1651 "make" | "cmake" | "ninja" => CommandCategory {
1653 category: "build",
1654 pattern: base_name.to_string(),
1655 },
1656 "tsc" => CommandCategory {
1657 category: "build",
1658 pattern: "tsc".to_string(),
1659 },
1660 "webpack" | "vite" | "esbuild" | "rollup" | "parcel" => CommandCategory {
1661 category: "build",
1662 pattern: base_name.to_string(),
1663 },
1664
1665 "pytest" | "jest" | "vitest" | "mocha" => CommandCategory {
1667 category: "test",
1668 pattern: base_name.to_string(),
1669 },
1670 "go" if sub == "test" => CommandCategory {
1671 category: "test",
1672 pattern: "go test".to_string(),
1673 },
1674 "ruby" if sub == "-e" || sub == "test" => CommandCategory {
1675 category: "test",
1676 pattern: "ruby test".to_string(),
1677 },
1678 "rspec" | "phpunit" => CommandCategory {
1679 category: "test",
1680 pattern: base_name.to_string(),
1681 },
1682
1683 "eslint" | "prettier" | "ruff" | "black" | "flake8" | "mypy" | "pylint" | "rubocop"
1685 | "biome" | "oxlint" => CommandCategory {
1686 category: "lint",
1687 pattern: base_name.to_string(),
1688 },
1689
1690 "git" | "gh" => {
1692 let git_sub = if sub.is_empty() { "git" } else { sub };
1693 CommandCategory {
1694 category: "git",
1695 pattern: format!("{} {}", base_name, git_sub),
1696 }
1697 }
1698
1699 "pip" | "pip3" if sub == "install" => CommandCategory {
1701 category: "install",
1702 pattern: "pip install".to_string(),
1703 },
1704 "apt" | "apt-get" | "brew" | "dnf" | "pacman" | "nix" => CommandCategory {
1705 category: "install",
1706 pattern: format!("{} {}", base_name, sub),
1707 },
1708
1709 "find" | "grep" | "rg" | "ag" | "fd" => CommandCategory {
1711 category: "search",
1712 pattern: base_name.to_string(),
1713 },
1714 "ls" | "cat" | "head" | "tail" | "wc" | "file" | "stat" | "tree" | "less" => {
1715 CommandCategory {
1716 category: "search",
1717 pattern: base_name.to_string(),
1718 }
1719 }
1720
1721 _ => CommandCategory {
1722 category: "other",
1723 pattern: base_name.to_string(),
1724 },
1725 }
1726}
1727
1728fn detect_retry_hotspots(
1733 invocations: &[(usize, String, bool)],
1734 output_tokens_per_turn: &[u64],
1735) -> Vec<RetryHotspot> {
1736 let mut by_pattern: HashMap<String, Vec<(usize, bool)>> = HashMap::new();
1738 for (turn_idx, pattern, was_error) in invocations {
1739 by_pattern
1740 .entry(pattern.clone())
1741 .or_default()
1742 .push((*turn_idx, *was_error));
1743 }
1744
1745 let mut hotspots = Vec::new();
1746 for (pattern, entries) in &by_pattern {
1747 let attempts = entries.len();
1748 let failures = entries.iter().filter(|(_, err)| *err).count();
1749 if failures >= 2 && attempts >= 3 {
1750 let turn_indices: Vec<usize> = entries.iter().map(|(idx, _)| *idx).collect();
1751 let output_tokens: u64 = turn_indices
1752 .iter()
1753 .filter_map(|&idx| output_tokens_per_turn.get(idx))
1754 .sum();
1755 hotspots.push(RetryHotspot {
1756 pattern: pattern.clone(),
1757 attempts,
1758 failures,
1759 output_tokens,
1760 turn_indices,
1761 });
1762 }
1763 }
1764
1765 hotspots.sort_by(|a, b| {
1767 b.failures
1768 .cmp(&a.failures)
1769 .then(b.output_tokens.cmp(&a.output_tokens))
1770 });
1771
1772 hotspots
1773}
1774
1775fn sort_tool_stats_by_hint(tools: &mut Vec<&ToolStats>, hint: Option<&str>) {
1780 let (field, descending) = match hint {
1781 None | Some("") | Some("calls") | Some("-calls") => ("calls", true),
1782 Some("+calls") => ("calls", false),
1783 Some("name") | Some("+name") => ("name", false),
1784 Some("-name") => ("name", true),
1785 Some("errors") | Some("-errors") => ("errors", true),
1786 Some("+errors") => ("errors", false),
1787 _ => ("calls", true), };
1789 match field {
1790 "name" => {
1791 if descending {
1792 tools.sort_by(|a, b| b.name.cmp(&a.name));
1793 } else {
1794 tools.sort_by(|a, b| a.name.cmp(&b.name));
1795 }
1796 }
1797 "errors" => {
1798 if descending {
1799 tools.sort_by(|a, b| b.errors.cmp(&a.errors).then(b.calls.cmp(&a.calls)));
1800 } else {
1801 tools.sort_by(|a, b| a.errors.cmp(&b.errors).then(a.calls.cmp(&b.calls)));
1802 }
1803 }
1804 _ => {
1805 if descending {
1807 tools.sort_by(|a, b| b.calls.cmp(&a.calls));
1808 } else {
1809 tools.sort_by(|a, b| a.calls.cmp(&b.calls));
1810 }
1811 }
1812 }
1813}
1814
1815fn build_command_stats(
1817 invocations: &[(usize, String, bool, &'static str)],
1818 output_tokens_per_turn: &[u64],
1819) -> Vec<CommandStats> {
1820 let mut by_category: HashMap<&str, HashMap<String, (usize, usize)>> = HashMap::new();
1822 let mut category_turns: HashMap<&str, Vec<usize>> = HashMap::new();
1823
1824 for (turn_idx, pattern, was_error, category) in invocations {
1825 let commands = by_category.entry(category).or_default();
1826 let entry = commands.entry(pattern.clone()).or_insert((0, 0));
1827 entry.0 += 1;
1828 if *was_error {
1829 entry.1 += 1;
1830 }
1831 category_turns.entry(category).or_default().push(*turn_idx);
1832 }
1833
1834 let mut stats: Vec<CommandStats> = by_category
1835 .into_iter()
1836 .map(|(category, commands)| {
1837 let total_calls: usize = commands.values().map(|(c, _)| c).sum();
1838 let total_errors: usize = commands.values().map(|(_, e)| e).sum();
1839
1840 let mut turns: Vec<usize> = category_turns.get(category).cloned().unwrap_or_default();
1842 turns.sort_unstable();
1843 turns.dedup();
1844 let output_tokens: u64 = turns
1845 .iter()
1846 .filter_map(|&idx| output_tokens_per_turn.get(idx))
1847 .sum();
1848
1849 let mut details: Vec<CommandDetail> = commands
1850 .into_iter()
1851 .map(|(pattern, (calls, errors))| CommandDetail {
1852 pattern,
1853 calls,
1854 errors,
1855 })
1856 .collect();
1857 details.sort_by(|a, b| b.calls.cmp(&a.calls));
1858
1859 CommandStats {
1860 category: category.to_string(),
1861 commands: details,
1862 total_calls,
1863 total_errors,
1864 output_tokens,
1865 }
1866 })
1867 .collect();
1868
1869 stats.sort_by(|a, b| b.total_calls.cmp(&a.total_calls));
1871 stats
1872}
1873
1874pub fn extract_tool_patterns(chains: &[ToolChain]) -> Vec<ToolPattern> {
1876 let mut pattern_counts: HashMap<Vec<String>, usize> = HashMap::new();
1877
1878 for chain in chains {
1879 for len in 2..=5.min(chain.tools.len()) {
1881 for start in 0..=chain.tools.len().saturating_sub(len) {
1882 let subsequence: Vec<String> = chain.tools[start..start + len].to_vec();
1883 *pattern_counts.entry(subsequence).or_insert(0) += 1;
1884 }
1885 }
1886 }
1887
1888 let mut patterns: Vec<ToolPattern> = pattern_counts
1890 .into_iter()
1891 .filter(|(_, count)| *count >= 2) .map(|(tools, occurrences)| ToolPattern { tools, occurrences })
1893 .collect();
1894
1895 patterns.sort_by(|a, b| {
1897 b.occurrences
1898 .cmp(&a.occurrences)
1899 .then(b.tools.len().cmp(&a.tools.len()))
1900 });
1901
1902 patterns
1903}
1904
1905pub fn analyze_session(session: &Session) -> SessionAnalysisReport {
1907 let mut analysis = SessionAnalysisReport::new(session.path.clone(), &session.format);
1908
1909 for turn in &session.turns {
1911 for msg in &turn.messages {
1912 *analysis
1913 .message_counts
1914 .entry(msg.role.to_string())
1915 .or_insert(0) += 1;
1916 }
1917 }
1918
1919 let mut current_chain: Option<Vec<(usize, String)>> = None;
1921
1922 let mut command_invocations: Vec<(usize, String, bool, &'static str)> = Vec::new();
1924 let mut retry_candidates: Vec<(usize, String, bool)> = Vec::new();
1926 let mut output_tokens_per_turn: Vec<u64> = Vec::new();
1928 let mut tool_result_candidates: Vec<(usize, usize, String, String)> = Vec::new();
1930
1931 for (turn_idx, turn) in session.turns.iter().enumerate() {
1932 let mut tool_uses_in_turn = 0;
1933 let mut tool_name_in_turn: Option<String> = None;
1934
1935 let mut bash_commands: HashMap<String, Vec<(String, &'static str)>> = HashMap::new();
1937 let mut tool_errors: HashMap<String, bool> = HashMap::new();
1939 let mut tool_id_to_name: HashMap<String, String> = HashMap::new();
1941
1942 for msg in &turn.messages {
1943 if msg.role == normalize_chat_sessions::Role::Assistant {
1945 for block in &msg.content {
1946 if let ContentBlock::Text { text } = block
1947 && let Some((category, excerpt)) = detect_correction(text)
1948 {
1949 analysis.corrections.push(Correction {
1950 turn: turn_idx,
1951 text: excerpt,
1952 category,
1953 });
1954 }
1955 }
1956 }
1957
1958 for block in &msg.content {
1959 match block {
1960 ContentBlock::ToolUse { id, name, input } => {
1961 let stat = analysis
1962 .tool_stats
1963 .entry(name.clone())
1964 .or_insert_with(|| ToolStats::new(name));
1965 stat.calls += 1;
1966 tool_uses_in_turn += 1;
1967 tool_name_in_turn = Some(name.clone());
1968 tool_id_to_name.insert(id.clone(), name.clone());
1969
1970 if let Some(file_path) = extract_file_path(name, input) {
1972 let op = analysis
1973 .file_operations
1974 .entry(file_path.clone())
1975 .or_insert_with(|| FileOperation {
1976 path: file_path.clone(),
1977 ..Default::default()
1978 });
1979 match name.as_str() {
1980 "Read" => op.reads += 1,
1981 "Edit" => op.edits += 1,
1982 "Write" => op.writes += 1,
1983 _ => {}
1984 }
1985 }
1986
1987 if name == "Bash"
1989 && let Some(cmd) = input.get("command").and_then(|v| v.as_str())
1990 {
1991 let subcmds = split_command_chain(cmd);
1992 let mut entries = Vec::new();
1993 for subcmd in subcmds {
1994 let cc = categorize_command(subcmd);
1995 entries.push((cc.pattern, cc.category));
1996 }
1997 bash_commands.insert(id.clone(), entries);
1998 }
1999 }
2000 ContentBlock::ToolResult {
2001 tool_use_id,
2002 is_error,
2003 content,
2004 ..
2005 } => {
2006 tool_errors.insert(tool_use_id.clone(), *is_error);
2008
2009 let content_chars = content.chars().count();
2011 if let Some(tool_name) = tool_id_to_name.get(tool_use_id) {
2012 if let Some(stat) = analysis.tool_stats.get_mut(tool_name) {
2013 stat.output_chars += content_chars;
2014 }
2015 let preview: String = content
2016 .chars()
2017 .take(100)
2018 .collect::<String>()
2019 .trim()
2020 .to_string();
2021 tool_result_candidates.push((
2022 content_chars,
2023 turn_idx,
2024 tool_name.clone(),
2025 preview,
2026 ));
2027 }
2028
2029 if *is_error {
2030 for m in &turn.messages {
2033 for b in &m.content {
2034 if let ContentBlock::ToolUse { id, name, .. } = b
2035 && id == tool_use_id
2036 && let Some(stat) = analysis.tool_stats.get_mut(name)
2037 {
2038 stat.errors += 1;
2039 }
2040 }
2041 }
2042
2043 let category = categorize_error(content);
2044 let pattern = analysis
2045 .error_patterns
2046 .iter_mut()
2047 .find(|p| p.category == category);
2048
2049 if let Some(p) = pattern {
2050 p.count += 1;
2051 if p.examples.len() < 3 {
2052 p.examples.push(content.chars().take(100).collect());
2053 }
2054 } else {
2055 let mut p = ErrorPattern::new(category);
2056 p.count = 1;
2057 p.examples.push(content.chars().take(100).collect());
2058 analysis.error_patterns.push(p);
2059 }
2060 }
2061 }
2062 _ => {}
2063 }
2064 }
2065 }
2066
2067 for (tool_id, entries) in &bash_commands {
2069 let was_error = tool_errors.get(tool_id).copied().unwrap_or(false);
2070 for (pattern, category) in entries {
2071 command_invocations.push((turn_idx, pattern.clone(), was_error, category));
2072 retry_candidates.push((turn_idx, pattern.clone(), was_error));
2073 }
2074 }
2075
2076 if tool_uses_in_turn == 1 {
2078 analysis.parallel_opportunities += 1;
2079
2080 if let Some(tool_name) = tool_name_in_turn {
2082 match &mut current_chain {
2083 Some(chain) => {
2084 chain.push((turn_idx, tool_name));
2085 }
2086 None => {
2087 current_chain = Some(vec![(turn_idx, tool_name)]);
2088 }
2089 }
2090 }
2091 } else {
2092 if let Some(chain) = current_chain.take()
2094 && chain.len() >= 3
2095 {
2096 let tools: Vec<String> = chain.iter().map(|(_, name)| name.clone()).collect();
2097 let turn_range = (chain[0].0, chain[chain.len() - 1].0);
2098 analysis.tool_chains.push(ToolChain { tools, turn_range });
2099 }
2100 }
2101 }
2102
2103 if let Some(chain) = current_chain
2105 && chain.len() >= 3
2106 {
2107 let tools: Vec<String> = chain.iter().map(|(_, name)| name.clone()).collect();
2108 let turn_range = (chain[0].0, chain[chain.len() - 1].0);
2109 analysis.tool_chains.push(ToolChain { tools, turn_range });
2110 }
2111
2112 tool_result_candidates.sort_by(|a, b| b.0.cmp(&a.0));
2114 analysis.largest_tool_results = tool_result_candidates
2115 .into_iter()
2116 .take(10)
2117 .map(|(chars, turn, tool_name, preview)| LargestToolResult {
2118 tool_name,
2119 chars,
2120 turn,
2121 preview,
2122 })
2123 .collect();
2124
2125 analysis.total_turns = session.turns.len();
2127 let mut actual_cost_sum: f64 = 0.0;
2128 let mut has_model_pricing = false;
2129 let mut prev_context = 0u64;
2130 let mut unique_input = 0u64;
2131
2132 for turn in &session.turns {
2133 if let Some(usage) = &turn.token_usage {
2134 analysis.token_stats.api_calls += 1;
2135 analysis.token_stats.total_input += usage.input;
2136 analysis.token_stats.total_output += usage.output;
2137 if let Some(cr) = usage.cache_read {
2138 analysis.token_stats.cache_read += cr;
2139 }
2140 if let Some(cc) = usage.cache_create {
2141 analysis.token_stats.cache_create += cc;
2142 }
2143
2144 let context = usage.input + usage.cache_read.unwrap_or(0);
2145 analysis.token_stats.update_context(context);
2146 analysis.context_per_turn.push(context);
2147 output_tokens_per_turn.push(usage.output);
2148
2149 if let Some(model_str) = &usage.model
2151 && let Some(pricing) = ModelPricing::from_model_str(model_str)
2152 {
2153 actual_cost_sum += pricing.calculate_turn_cost(usage);
2154 has_model_pricing = true;
2155 }
2156
2157 unique_input += context.saturating_sub(prev_context);
2159 prev_context = context;
2160 } else {
2161 analysis.context_per_turn.push(0);
2162 output_tokens_per_turn.push(0);
2163 }
2164 }
2165
2166 if has_model_pricing {
2167 analysis.actual_cost = Some(actual_cost_sum);
2168 }
2169
2170 let total_billed = analysis.token_stats.total_input
2172 + analysis.token_stats.cache_read
2173 + analysis.token_stats.total_output;
2174 if total_billed > 0 {
2175 let unique_output = analysis.token_stats.total_output;
2176 let unique_total = unique_input + unique_output;
2177 analysis.dedup_tokens = Some(DedupTokenStats {
2178 unique_input,
2179 unique_output,
2180 total_billed,
2181 uniqueness_ratio: unique_total as f64 / total_billed as f64,
2182 });
2183 }
2184
2185 analysis.command_stats = build_command_stats(&command_invocations, &output_tokens_per_turn);
2187 analysis.retry_hotspots = detect_retry_hotspots(&retry_candidates, &output_tokens_per_turn);
2188
2189 analysis
2191 .error_patterns
2192 .sort_by(|a, b| b.count.cmp(&a.count));
2193
2194 analysis
2195}