Skip to main content

normalize_session_analysis/
lib.rs

1//! Session analysis types and functions.
2//!
3//! This module computes analytics from parsed Session data.
4//! Analysis is intentionally in the CLI, not the parsing library,
5//! because what metrics matter is subjective and consumer-specific.
6
7use normalize_chat_sessions::{ContentBlock, Session};
8use normalize_output::OutputFormatter;
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use std::path::PathBuf;
12
13/// Statistics for a single tool.
14#[derive(Debug, Clone, Default, Serialize, schemars::JsonSchema, Deserialize)]
15pub struct ToolStats {
16    pub name: String,
17    pub calls: usize,
18    pub errors: usize,
19    /// Total characters across all tool result content for this tool.
20    pub output_chars: usize,
21}
22
23/// A single large tool result, for the top-N results by character count.
24#[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    /// First ~100 chars of content, trimmed.
30    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/// Token usage statistics.
53#[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/// Model pricing information (per million tokens).
65#[derive(Debug, Clone, Copy)]
66pub struct ModelPricing {
67    /// Human-readable model name (e.g. `"Claude Sonnet 4.5"`).
68    pub name: &'static str,
69    /// Cost in USD per million input tokens.
70    pub input_per_mtok: f64,
71    /// Cost in USD per million output tokens.
72    pub output_per_mtok: f64,
73    /// Cost in USD per million tokens written to the prompt cache.
74    pub cache_write_per_mtok: f64,
75    /// Cost in USD per million tokens read from the prompt cache.
76    pub cache_read_per_mtok: f64,
77}
78
79impl ModelPricing {
80    /// Anthropic Claude pricing (as of Feb 2026).
81    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    /// Look up pricing from a model identifier string (e.g. `"claude-opus-4-6"`).
154    ///
155    /// Matching is case-insensitive and uses substring search on the model
156    /// identifier. The family (`opus`, `sonnet`, `haiku`) is detected first,
157    /// then the version number within that family. Returns `None` for unknown
158    /// model identifiers.
159    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                // Default to latest sonnet pricing for unrecognized variants
178                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    /// Calculate cost for a single turn's token usage.
194    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    /// Calculate cost for given token usage.
205    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        // Cache savings = what we would have paid without cache
213        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/// Cost breakdown for a session.
231#[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/// A recurring error pattern.
262#[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/// A sequence of consecutive single-tool calls (potential parallelization).
280#[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    /// Estimate potential API call savings if parallelized.
296    pub fn potential_savings(&self) -> usize {
297        if self.len() <= 1 { 0 } else { self.len() - 1 }
298    }
299
300    /// Check if chain contains only read-like operations (safe to parallelize).
301    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/// Type of correction made by the assistant.
312#[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/// An assistant correction or apology.
333#[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/// File operation statistics.
341#[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/// Statistics for a command category (e.g., "build", "test", "git").
356#[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    /// Sum of output tokens for turns containing this category.
363    pub output_tokens: u64,
364}
365
366/// Detail for a specific command pattern within a category.
367#[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/// A command pattern that failed and was retried.
375#[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/// A common tool pattern across sessions.
385#[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/// Token deduplication statistics.
398#[derive(Debug, Clone, Default, Serialize, schemars::JsonSchema, Deserialize)]
399pub struct DedupTokenStats {
400    /// Input tokens representing genuinely new content.
401    pub unique_input: u64,
402    /// Output tokens (always unique).
403    pub unique_output: u64,
404    /// Total billed tokens (input + output).
405    pub total_billed: u64,
406    /// Ratio of unique tokens to total billed (0.0-1.0).
407    pub uniqueness_ratio: f64,
408}
409
410/// Complete analysis of a session.
411#[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    /// Token usage per file/symbol path
420    pub file_tokens: HashMap<String, u64>,
421    /// Turns with single tool call (parallelization opportunity)
422    pub parallel_opportunities: usize,
423    pub total_turns: usize,
424    /// Sequences of consecutive single-tool calls
425    pub tool_chains: Vec<ToolChain>,
426    /// Assistant corrections and apologies
427    pub corrections: Vec<Correction>,
428    /// Context size per turn (input + cache_read)
429    pub context_per_turn: Vec<u64>,
430    /// File operation frequency (Read/Edit/Write)
431    pub file_operations: HashMap<String, FileOperation>,
432    /// Common tool patterns (multi-session aggregate only)
433    pub tool_patterns: Vec<ToolPattern>,
434    /// Bash command statistics by category
435    pub command_stats: Vec<CommandStats>,
436    /// Commands that failed and were retried
437    pub retry_hotspots: Vec<RetryHotspot>,
438    /// Actual cost computed from per-turn model pricing (None if no models found).
439    pub actual_cost: Option<f64>,
440    /// Token deduplication statistics.
441    pub dedup_tokens: Option<DedupTokenStats>,
442    /// Top 10 individual tool results by character count.
443    pub largest_tool_results: Vec<LargestToolResult>,
444    /// Sort hint for tool rows in formatted output.
445    /// Valid values: "name" (asc), "calls" (desc, default), "errors" (desc).
446    /// Set by the CLI `--sort` flag; not serialized.
447    #[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    /// Format as compact text (markdown, LLM-friendly, no colors).
479    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        // Message types
500        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        // Tool usage
514        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        // Largest tool results
534        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        // Token usage
549        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            // Cost breakdown
574            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            // Token efficiency
631            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            // Token growth
657            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                // Show growth at key intervals
662                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        // Command breakdown
697        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            // Top commands across all categories
714            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        // Retry hotspots
737        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        // Token hotspots
753        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        // File operations heatmap
767        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        // Parallelization hints
788        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        // Tool patterns (multi-session only)
832        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        // Tool chains
848        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        // Corrections
870        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        // Error patterns
885        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    /// Format as pretty text with colors and bar charts.
901    pub fn format_pretty(&self) -> String {
902        let mut out = String::new();
903        // Writing to String via fmt::Write is infallible — String::write_fmt never returns Err.
904        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        // Header
912        writeln!(out, "\x1b[1;36m━━━ Session Analysis ━━━\x1b[0m")?;
913        writeln!(out)?;
914
915        // Summary
916        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        // Tool usage with bar charts
934        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        // Largest tool results
972        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        // Token usage
988        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            // Cost breakdown
1011            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            // Token efficiency
1070            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            // Token growth visualization
1092            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        // Command breakdown with bar charts
1102        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        // Retry hotspots
1148        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        // File operations heatmap
1164        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                // Build readable operation summary
1176                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        // Token hotspots
1205        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        // Message types (compact)
1222        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        // Parallelization opportunities
1236        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        // Tool patterns (multi-session aggregate)
1280        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        // Tool chains
1296        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        // Corrections
1318        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
1336/// Implement OutputFormatter trait for consistent output handling.
1337impl OutputFormatter for SessionAnalysisReport {
1338    fn format_text(&self) -> String {
1339        // Call the inherent method (markdown format)
1340        SessionAnalysisReport::format_text(self)
1341    }
1342
1343    fn format_pretty(&self) -> String {
1344        // Call the inherent method (colored bar charts)
1345        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
1355/// Format token count with K/M suffix.
1356fn 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
1366/// Generate ASCII bar chart for token growth.
1367fn 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    // Sample turns if too many (show every Nth turn)
1379    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; // Skip turns without token usage
1388        }
1389        if idx % sample_rate != 0 && idx != context_per_turn.len() - 1 {
1390            continue; // Skip non-sampled turns, but always show last
1391        }
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        // Color based on threshold
1397        let color = if context >= threshold_100k {
1398            "\x1b[31m" // Red: dangerous
1399        } else if context >= threshold_80k {
1400            "\x1b[33m" // Yellow: warning
1401        } else {
1402            "\x1b[32m" // Green: ok
1403        };
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
1427/// Categorize an error by its content.
1428pub 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
1447/// Extract file path from tool input JSON.
1448fn 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
1460/// Detect correction patterns in assistant text.
1461/// Returns (category, excerpt) if a correction is found.
1462pub fn detect_correction(text: &str) -> Option<(CorrectionKind, String)> {
1463    let lower = text.to_lowercase();
1464
1465    // Look for apology patterns
1466    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    // Look for mistake acknowledgment
1475    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    // Look for "let me fix" patterns
1489    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    // Look for "actually" corrections
1498    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
1509/// Normalize a file path for aggregation.
1510pub fn normalize_path(path: &str) -> String {
1511    if !path.starts_with('/') {
1512        return path.to_string();
1513    }
1514    // Find common project markers and make relative
1515    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
1527/// Split a shell command line on `&&`, `;`, and `||` into individual commands.
1528fn 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; // skip extra char
1548        } 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    // Filter out comments and empty-ish entries
1563    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
1623/// Categorize a single shell command and return category + normalized pattern.
1624///
1625/// The normalized pattern is the base command + subcommand (e.g. "cargo test", "npm run build").
1626pub fn categorize_command(cmd: &str) -> CommandCategory {
1627    // Strip leading env vars (KEY=val cmd ...) and cd prefixes
1628    let cmd = cmd.trim();
1629    // Skip env var assignments at the start
1630    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    // Extract the binary name from any path (e.g. ./target/debug/cargo -> cargo)
1645    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        // Build tools
1652        "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        // Test tools
1666        "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        // Lint/format tools
1684        "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
1691        "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        // Install/dependency
1700        "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        // Search/read tools
1710        "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
1728/// Detect retry hotspots from a sequence of command invocations.
1729///
1730/// Takes `(turn_idx, normalized_pattern, was_error)` triples and groups
1731/// them by pattern. A hotspot = pattern with >= 2 failures out of >= 3 attempts.
1732fn detect_retry_hotspots(
1733    invocations: &[(usize, String, bool)],
1734    output_tokens_per_turn: &[u64],
1735) -> Vec<RetryHotspot> {
1736    // Group by normalized pattern
1737    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    // Sort by failures descending, then by output_tokens descending
1766    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
1775/// Sort a mutable slice of `ToolStats` references according to a sort hint string.
1776/// Valid hints: `"name"` (ascending), `"calls"` (descending, default),
1777/// `"errors"` (descending), `"+name"` / `"-calls"` etc.
1778/// Unknown or empty hints fall back to calls-descending.
1779fn 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), // fallback
1788    };
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            // calls
1806            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
1815/// Build command stats from invocation data.
1816fn build_command_stats(
1817    invocations: &[(usize, String, bool, &'static str)],
1818    output_tokens_per_turn: &[u64],
1819) -> Vec<CommandStats> {
1820    // Group by category
1821    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            // Deduplicate turn indices and sum output tokens
1841            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    // Sort by total_calls descending
1870    stats.sort_by(|a, b| b.total_calls.cmp(&a.total_calls));
1871    stats
1872}
1873
1874/// Extract all subsequences of length 2-5 from tool chains.
1875pub 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        // Extract all subsequences of length 2-5
1880        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    // Convert to ToolPattern vec and filter out single occurrences
1889    let mut patterns: Vec<ToolPattern> = pattern_counts
1890        .into_iter()
1891        .filter(|(_, count)| *count >= 2) // Only keep patterns that occur 2+ times
1892        .map(|(tools, occurrences)| ToolPattern { tools, occurrences })
1893        .collect();
1894
1895    // Sort by occurrence count (descending), then by pattern length (descending)
1896    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
1905/// Analyze a parsed session and compute statistics.
1906pub fn analyze_session(session: &Session) -> SessionAnalysisReport {
1907    let mut analysis = SessionAnalysisReport::new(session.path.clone(), &session.format);
1908
1909    // Count message types by role. Role::User = human input, Role::Tool = tool results.
1910    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    // Analyze tool usage, detect tool chains, and collect command data
1920    let mut current_chain: Option<Vec<(usize, String)>> = None;
1921
1922    // For command analysis: (turn_idx, pattern, was_error, category)
1923    let mut command_invocations: Vec<(usize, String, bool, &'static str)> = Vec::new();
1924    // For retry detection: (turn_idx, pattern, was_error)
1925    let mut retry_candidates: Vec<(usize, String, bool)> = Vec::new();
1926    // Output tokens per turn index (populated in the token usage pass below)
1927    let mut output_tokens_per_turn: Vec<u64> = Vec::new();
1928    // Candidates for largest tool results: (chars, turn_idx, tool_name, preview)
1929    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        // Collect Bash tool_use IDs and their commands for this turn
1936        let mut bash_commands: HashMap<String, Vec<(String, &'static str)>> = HashMap::new();
1937        // Track which tool_use IDs had errors
1938        let mut tool_errors: HashMap<String, bool> = HashMap::new();
1939        // Map tool_use_id -> tool_name for result attribution
1940        let mut tool_id_to_name: HashMap<String, String> = HashMap::new();
1941
1942        for msg in &turn.messages {
1943            // Detect corrections in assistant messages
1944            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                        // Track file operations
1971                        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                        // Collect Bash commands for categorization
1988                        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                        // Track error status for Bash tool matching
2007                        tool_errors.insert(tool_use_id.clone(), *is_error);
2008
2009                        // Accumulate output_chars and collect largest result candidates
2010                        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                            // Attribute error to tool stat
2031                            // Find which tool this result belongs to by scanning the turn
2032                            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        // Process collected bash commands for this turn
2068        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        // Track parallel opportunities (turns with single tool call)
2077        if tool_uses_in_turn == 1 {
2078            analysis.parallel_opportunities += 1;
2079
2080            // Build tool chains
2081            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            // Chain broken - save if length >= 3
2093            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    // Handle final chain
2104    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    // Build largest_tool_results: top 10 individual results by char count
2113    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    // Analyze token usage and build output_tokens_per_turn
2126    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            // Actual cost from per-turn model
2150            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            // Dedup: unique input = only context growth
2158            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    // Compute dedup token stats
2171    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    // Build command stats and retry hotspots
2186    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    // Sort error patterns by count
2190    analysis
2191        .error_patterns
2192        .sort_by(|a, b| b.count.cmp(&a.count));
2193
2194    analysis
2195}