terraphim_session_analyzer/
reporter.rs

1use crate::models::{
2    AgentAttribution, AgentStatistics, AgentToolCorrelation, CollaborationPattern, SessionAnalysis,
3    ToolAnalysis,
4};
5use anyhow::Result;
6use colored::Colorize;
7use indexmap::IndexMap;
8use std::collections::{HashMap, HashSet};
9use std::fmt::Write as FmtWrite;
10use tabled::{
11    settings::{object::Columns, Modify, Style, Width},
12    Table, Tabled,
13};
14
15pub struct Reporter {
16    show_colors: bool,
17}
18
19impl Reporter {
20    #[must_use]
21    pub fn new() -> Self {
22        Self { show_colors: true }
23    }
24
25    #[must_use]
26    pub fn with_colors(mut self, show_colors: bool) -> Self {
27        self.show_colors = show_colors;
28        self
29    }
30
31    /// Print analysis results to terminal with rich formatting
32    pub fn print_terminal(&self, analyses: &[SessionAnalysis]) {
33        if analyses.is_empty() {
34            println!("{}", "No sessions found to analyze".yellow());
35            return;
36        }
37
38        // Print header
39        self.print_header(analyses);
40
41        // Print each session analysis
42        for (i, analysis) in analyses.iter().enumerate() {
43            if i > 0 {
44                println!();
45            }
46            self.print_session_analysis(analysis);
47        }
48
49        // Print summary if multiple sessions
50        if analyses.len() > 1 {
51            println!();
52            self.print_summary(analyses);
53        }
54    }
55
56    fn print_header(&self, analyses: &[SessionAnalysis]) {
57        let title = if analyses.len() == 1 {
58            "Claude Session Analysis"
59        } else {
60            "Claude Sessions Analysis"
61        };
62
63        println!("{}", format!("═══ {} ═══", title).bold().cyan());
64
65        if analyses.len() > 1 {
66            println!(
67                "{} {}",
68                "Sessions analyzed:".bold(),
69                analyses.len().to_string().yellow()
70            );
71        }
72        println!();
73    }
74
75    fn print_session_analysis(&self, analysis: &SessionAnalysis) {
76        // Session info
77        println!("{} {}", "Session:".bold(), analysis.session_id.yellow());
78        println!("{} {}", "Project:".bold(), analysis.project_path.green());
79        println!("{} {}ms", "Duration:".bold(), analysis.duration_ms);
80
81        if !analysis.agents.is_empty() {
82            println!("{} {}", "Agents used:".bold(), analysis.agents.len());
83        }
84
85        // File attributions table
86        if !analysis.file_to_agents.is_empty() {
87            println!("\n{}", "📊 File Contributions:".bold());
88            self.print_file_attributions(&analysis.file_to_agents);
89        }
90
91        // Agent statistics
92        if !analysis.agent_stats.is_empty() {
93            println!("\n{}", "👥 Agent Statistics:".bold());
94            self.print_agent_statistics(&analysis.agent_stats);
95        }
96
97        // Timeline
98        if !analysis.agents.is_empty() {
99            println!("\n{}", "⏱️ Timeline:".bold());
100            self.print_timeline(analysis);
101        }
102
103        // Collaboration patterns
104        if !analysis.collaboration_patterns.is_empty() {
105            println!("\n{}", "🔗 Collaboration Patterns:".bold());
106            self.print_collaboration_patterns(&analysis.collaboration_patterns);
107        }
108    }
109
110    fn print_file_attributions(&self, file_to_agents: &IndexMap<String, Vec<AgentAttribution>>) {
111        let mut table_data = Vec::new();
112
113        for (file_path, attributions) in file_to_agents {
114            let file_display = self.truncate_path(file_path, 40);
115
116            for (i, attr) in attributions.iter().enumerate() {
117                let file_col = if i == 0 {
118                    file_display.clone()
119                } else {
120                    String::new()
121                };
122
123                table_data.push(FileRow {
124                    file: file_col,
125                    agent: self.format_agent_display(&attr.agent_type),
126                    contribution: format!("{:.1}%", attr.contribution_percent),
127                    confidence: format!("{:.0}%", attr.confidence_score * 100.0),
128                    operations: attr.operations.len().to_string(),
129                });
130            }
131        }
132
133        if !table_data.is_empty() {
134            let table = Table::new(table_data)
135                .with(Style::modern())
136                .with(Modify::new(Columns::first()).with(Width::wrap(40)))
137                .to_string();
138            println!("{}", table);
139        }
140    }
141
142    fn print_agent_statistics(&self, agent_stats: &IndexMap<String, AgentStatistics>) {
143        let mut table_data = Vec::new();
144
145        for stats in agent_stats.values() {
146            table_data.push(AgentRow {
147                agent: self.format_agent_display(&stats.agent_type),
148                invocations: stats.total_invocations.to_string(),
149                duration: self.format_duration(stats.total_duration_ms),
150                files: stats.files_touched.to_string(),
151                tools: stats.tools_used.len().to_string(),
152            });
153        }
154
155        if !table_data.is_empty() {
156            let table = Table::new(table_data).with(Style::modern()).to_string();
157            println!("{}", table);
158        }
159    }
160
161    fn print_timeline(&self, analysis: &SessionAnalysis) {
162        let mut events: Vec<_> = analysis
163            .agents
164            .iter()
165            .map(|a| (a.timestamp, &a.agent_type, &a.task_description))
166            .collect();
167
168        events.sort_by(|a, b| a.0.cmp(&b.0));
169
170        for (timestamp, agent_type, description) in events.iter().take(10) {
171            let time_str = self.format_timestamp(*timestamp);
172            let agent_display = self.format_agent_display(agent_type);
173            let desc = self.truncate_text(description, 60);
174
175            println!(
176                "  {} {} - {}",
177                time_str.dimmed(),
178                agent_display,
179                desc.dimmed()
180            );
181        }
182
183        if events.len() > 10 {
184            println!(
185                "  {} {} more events...",
186                "...".dimmed(),
187                (events.len() - 10).to_string().dimmed()
188            );
189        }
190    }
191
192    fn print_collaboration_patterns(&self, patterns: &[CollaborationPattern]) {
193        for pattern in patterns {
194            let agents_display = pattern
195                .agents
196                .iter()
197                .map(|a| self.format_agent_icon(a))
198                .collect::<Vec<_>>()
199                .join(" → ");
200
201            println!(
202                "  {} {} ({}% confidence)",
203                agents_display,
204                pattern.description.dimmed(),
205                (pattern.confidence * 100.0) as u32
206            );
207        }
208    }
209
210    fn print_summary(&self, analyses: &[SessionAnalysis]) {
211        println!("{}", "📈 Summary Statistics:".bold());
212
213        // Calculate totals
214        let total_agents: usize = analyses.iter().map(|a| a.agents.len()).sum();
215        let total_files: usize = analyses.iter().map(|a| a.file_to_agents.len()).sum();
216        let total_duration: u64 = analyses.iter().map(|a| a.duration_ms).sum();
217
218        // Most active agents across all sessions
219        let mut agent_counts: IndexMap<String, u32> = IndexMap::new();
220        for analysis in analyses {
221            for agent in &analysis.agents {
222                *agent_counts.entry(agent.agent_type.clone()).or_insert(0) += 1;
223            }
224        }
225
226        let mut sorted_agents: Vec<_> = agent_counts.into_iter().collect();
227        sorted_agents.sort_by(|a, b| b.1.cmp(&a.1));
228
229        println!("  {} {}", "Total agent invocations:".bold(), total_agents);
230        println!("  {} {}", "Total files modified:".bold(), total_files);
231        println!(
232            "  {} {}",
233            "Total session time:".bold(),
234            self.format_duration(total_duration)
235        );
236
237        println!("\n{}", "🏆 Most Active Agents:".bold());
238        for (agent, count) in sorted_agents.iter().take(5) {
239            println!(
240                "  {} {} ({}x)",
241                self.format_agent_icon(agent),
242                agent.cyan(),
243                count.to_string().yellow()
244            );
245        }
246    }
247
248    /// Generate markdown report
249    pub fn to_markdown(&self, analyses: &[SessionAnalysis]) -> Result<String> {
250        let mut md = String::new();
251
252        writeln!(md, "# Claude Session Analysis Report\n")?;
253
254        if analyses.len() > 1 {
255            writeln!(md, "**Sessions Analyzed**: {}\n", analyses.len())?;
256        }
257
258        for (i, analysis) in analyses.iter().enumerate() {
259            if analyses.len() > 1 {
260                writeln!(md, "## Session {} - {}\n", i + 1, analysis.session_id)?;
261            } else {
262                writeln!(md, "## Session Analysis\n")?;
263            }
264
265            writeln!(md, "- **Session ID**: `{}`", analysis.session_id)?;
266            writeln!(md, "- **Project**: `{}`", analysis.project_path)?;
267            writeln!(md, "- **Duration**: {} ms", analysis.duration_ms)?;
268            writeln!(md, "- **Agents Used**: {}", analysis.agents.len())?;
269            writeln!(
270                md,
271                "- **Files Modified**: {}\n",
272                analysis.file_to_agents.len()
273            )?;
274
275            if !analysis.file_to_agents.is_empty() {
276                writeln!(md, "### Files Created/Modified\n")?;
277
278                for (file_path, attributions) in &analysis.file_to_agents {
279                    writeln!(md, "#### `{}`\n", file_path)?;
280                    writeln!(md, "| Agent | Contribution | Confidence | Operations |")?;
281                    writeln!(md, "|-------|-------------|------------|------------|")?;
282
283                    for attr in attributions {
284                        writeln!(
285                            md,
286                            "| {} | {:.1}% | {:.0}% | {} |",
287                            attr.agent_type,
288                            attr.contribution_percent,
289                            attr.confidence_score * 100.0,
290                            attr.operations.len()
291                        )?;
292                    }
293                    writeln!(md)?;
294                }
295            }
296
297            if !analysis.collaboration_patterns.is_empty() {
298                writeln!(md, "### Collaboration Patterns\n")?;
299                for pattern in &analysis.collaboration_patterns {
300                    writeln!(
301                        md,
302                        "- **{}**: {} ({:.0}% confidence)",
303                        pattern.pattern_type,
304                        pattern.description,
305                        pattern.confidence * 100.0
306                    )?;
307                }
308                writeln!(md)?;
309            }
310        }
311
312        Ok(md)
313    }
314
315    /// Generate JSON report
316    pub fn to_json(&self, analyses: &[SessionAnalysis]) -> Result<String> {
317        let json = if analyses.len() == 1 {
318            serde_json::to_string_pretty(&analyses[0])?
319        } else {
320            serde_json::to_string_pretty(analyses)?
321        };
322        Ok(json)
323    }
324
325    /// Generate CSV report
326    pub fn to_csv(&self, analyses: &[SessionAnalysis]) -> Result<String> {
327        let mut csv_data = Vec::new();
328
329        // Add header
330        csv_data.push(vec![
331            "session_id".to_string(),
332            "file_path".to_string(),
333            "agent_type".to_string(),
334            "contribution_percent".to_string(),
335            "confidence_score".to_string(),
336            "operations_count".to_string(),
337        ]);
338
339        // Add data rows
340        for analysis in analyses {
341            for (file_path, attributions) in &analysis.file_to_agents {
342                for attr in attributions {
343                    csv_data.push(vec![
344                        analysis.session_id.clone(),
345                        file_path.clone(),
346                        attr.agent_type.clone(),
347                        attr.contribution_percent.to_string(),
348                        attr.confidence_score.to_string(),
349                        attr.operations.len().to_string(),
350                    ]);
351                }
352            }
353        }
354
355        let mut csv_output = String::new();
356        for row in csv_data {
357            writeln!(csv_output, "{}", row.join(","))?;
358        }
359
360        Ok(csv_output)
361    }
362
363    // Helper formatting functions
364
365    fn format_agent_display(&self, agent_type: &str) -> String {
366        if self.show_colors {
367            format!(
368                "{} {}",
369                self.format_agent_icon(agent_type),
370                agent_type.cyan()
371            )
372        } else {
373            format!("{} {}", self.format_agent_icon(agent_type), agent_type)
374        }
375    }
376
377    pub fn format_agent_icon(&self, agent_type: &str) -> String {
378        match agent_type {
379            "architect" => "🏗️".to_string(),
380            "developer" => "💻".to_string(),
381            "backend-architect" => "🔧".to_string(),
382            "frontend-developer" => "🎨".to_string(),
383            "rust-performance-expert" => "🦀".to_string(),
384            "rust-code-reviewer" => "🔍".to_string(),
385            "debugger" => "🐛".to_string(),
386            "technical-writer" => "📝".to_string(),
387            "test-writer-fixer" => "🧪".to_string(),
388            "rapid-prototyper" => "⚡".to_string(),
389            "devops-automator" => "🚀".to_string(),
390            "overseer" => "👁️".to_string(),
391            "ai-engineer" => "🤖".to_string(),
392            "general-purpose" => "🎯".to_string(),
393            _ => "🔧".to_string(),
394        }
395    }
396
397    fn format_timestamp(&self, timestamp: jiff::Timestamp) -> String {
398        timestamp.strftime("%H:%M:%S").to_string()
399    }
400
401    fn format_duration(&self, duration_ms: u64) -> String {
402        if duration_ms < 1000 {
403            format!("{}ms", duration_ms)
404        } else if duration_ms < 60_000 {
405            format!("{:.1}s", duration_ms as f64 / 1000.0)
406        } else if duration_ms < 3_600_000 {
407            format!("{:.1}m", duration_ms as f64 / 60_000.0)
408        } else {
409            format!("{:.1}h", duration_ms as f64 / 3_600_000.0)
410        }
411    }
412
413    fn truncate_path(&self, path: &str, max_len: usize) -> String {
414        if path.len() <= max_len {
415            path.to_string()
416        } else {
417            let start_len = max_len / 3;
418            let end_len = max_len - start_len - 3;
419            format!("{}...{}", &path[..start_len], &path[path.len() - end_len..])
420        }
421    }
422
423    fn truncate_text(&self, text: &str, max_len: usize) -> String {
424        if text.len() <= max_len {
425            text.to_string()
426        } else {
427            format!("{}...", &text[..max_len - 3])
428        }
429    }
430
431    /// Print tool usage analysis to terminal
432    #[allow(dead_code)] // Replaced by print_tool_analysis_detailed
433    pub fn print_tool_analysis(
434        &self,
435        stats: &std::collections::HashMap<String, crate::models::ToolStatistics>,
436    ) {
437        if stats.is_empty() {
438            println!("{}", "No tool usage found".yellow());
439            return;
440        }
441
442        println!("{}", "Tool Usage Analysis".bold().cyan());
443        println!();
444
445        // Convert to sorted vector
446        let mut tool_stats: Vec<_> = stats.iter().collect();
447        tool_stats.sort_by(|a, b| b.1.total_invocations.cmp(&a.1.total_invocations));
448
449        // Create table rows
450        let mut rows = Vec::new();
451        for (tool_name, stat) in tool_stats {
452            let agents_str = if stat.agents_using.is_empty() {
453                "-".to_string()
454            } else {
455                stat.agents_using.join(", ")
456            };
457
458            let sessions_str = format!("{} sessions", stat.sessions.len());
459            let category_str = format!("{:?}", stat.category);
460
461            rows.push(ToolRow {
462                tool: tool_name.clone(),
463                count: stat.total_invocations.to_string(),
464                category: category_str,
465                agents: self.truncate_text(&agents_str, 40),
466                sessions: sessions_str,
467            });
468        }
469
470        let table = Table::new(rows)
471            .with(Style::modern())
472            .with(Modify::new(Columns::single(0)).with(Width::wrap(20)))
473            .with(Modify::new(Columns::single(3)).with(Width::wrap(40)))
474            .to_string();
475
476        println!("{table}");
477        println!();
478        println!(
479            "{} {} unique tools found",
480            "Total:".bold(),
481            stats.len().to_string().yellow()
482        );
483    }
484
485    /// Print detailed tool analysis with correlation matrix
486    pub fn print_tool_analysis_detailed(
487        &self,
488        analysis: &ToolAnalysis,
489        show_correlation: bool,
490    ) -> Result<()> {
491        if analysis.tool_statistics.is_empty() {
492            println!("{}", "No tool usage found".yellow());
493            return Ok(());
494        }
495
496        // Header
497        println!("{}", "═══ Tool Analysis ═══".bold().cyan());
498        println!();
499
500        // Summary statistics
501        println!("{}", "📊 Summary:".bold());
502        println!(
503            "  {} {}",
504            "Total Tool Invocations:".bold(),
505            analysis.total_tool_invocations.to_string().yellow()
506        );
507        println!(
508            "  {} {}",
509            "Unique Tools:".bold(),
510            analysis.tool_statistics.len().to_string().yellow()
511        );
512        println!(
513            "  {} {}",
514            "Tool Categories:".bold(),
515            analysis.category_breakdown.len().to_string().yellow()
516        );
517        println!();
518
519        // Tool frequency table
520        println!("{}", "🔧 Tool Frequency:".bold());
521        let mut tool_rows = Vec::new();
522        for (tool_name, stat) in &analysis.tool_statistics {
523            let agents_str = if stat.agents_using.is_empty() {
524                "-".to_string()
525            } else {
526                stat.agents_using.join(", ")
527            };
528
529            let success_rate = if stat.total_invocations > 0 {
530                #[allow(clippy::cast_precision_loss)]
531                let rate = (stat.success_count as f32 / stat.total_invocations as f32) * 100.0;
532                format!("{:.1}%", rate)
533            } else {
534                "-".to_string()
535            };
536
537            tool_rows.push(DetailedToolRow {
538                tool: tool_name.clone(),
539                count: stat.total_invocations.to_string(),
540                category: format!("{:?}", stat.category),
541                agents: self.truncate_text(&agents_str, 30),
542                success_rate,
543                sessions: stat.sessions.len().to_string(),
544            });
545        }
546
547        // Sort by invocation count
548        tool_rows.sort_by(|a, b| {
549            b.count
550                .parse::<u32>()
551                .unwrap_or(0)
552                .cmp(&a.count.parse::<u32>().unwrap_or(0))
553        });
554
555        let table = Table::new(tool_rows)
556            .with(Style::modern())
557            .with(Modify::new(Columns::single(0)).with(Width::wrap(20)))
558            .with(Modify::new(Columns::single(3)).with(Width::wrap(30)))
559            .to_string();
560        println!("{}", table);
561        println!();
562
563        // Category breakdown
564        println!("{}", "📂 Category Breakdown:".bold());
565        let mut category_rows: Vec<_> = analysis
566            .category_breakdown
567            .iter()
568            .map(|(cat, count)| (format!("{:?}", cat), *count))
569            .collect();
570        category_rows.sort_by(|a, b| b.1.cmp(&a.1));
571
572        for (category, count) in category_rows {
573            #[allow(clippy::cast_precision_loss)]
574            let percentage = (count as f32 / analysis.total_tool_invocations as f32) * 100.0;
575            println!(
576                "  {} {} ({:.1}%)",
577                category.cyan(),
578                count.to_string().yellow(),
579                percentage
580            );
581        }
582        println!();
583
584        // Correlation matrix if requested
585        if show_correlation && !analysis.agent_tool_correlations.is_empty() {
586            self.print_correlation_matrix(&analysis.agent_tool_correlations);
587        }
588
589        Ok(())
590    }
591
592    /// Print agent-tool correlation matrix using Unicode blocks
593    pub fn print_correlation_matrix(&self, correlations: &[AgentToolCorrelation]) {
594        println!("{}", "🔗 Agent-Tool Correlation Matrix:".bold());
595        println!();
596
597        // Build matrix structure
598        let mut agents: Vec<String> = correlations
599            .iter()
600            .map(|c| c.agent_type.clone())
601            .collect::<HashSet<_>>()
602            .into_iter()
603            .collect();
604        agents.sort();
605
606        let mut tools: Vec<String> = correlations
607            .iter()
608            .map(|c| c.tool_name.clone())
609            .collect::<HashSet<_>>()
610            .into_iter()
611            .collect();
612        tools.sort();
613
614        // Build lookup map
615        let mut correlation_map: HashMap<(String, String), &AgentToolCorrelation> = HashMap::new();
616        for corr in correlations {
617            correlation_map.insert((corr.agent_type.clone(), corr.tool_name.clone()), corr);
618        }
619
620        // Print header row
621        print!("{:15}", "");
622        for tool in &tools {
623            print!("{:12}", self.truncate_text(tool, 10));
624        }
625        println!();
626
627        // Print separator
628        print!("{:15}", "");
629        for _ in &tools {
630            print!("{:12}", "─".repeat(10));
631        }
632        println!();
633
634        // Print each agent row
635        for agent in &agents {
636            print!("{:15}", self.truncate_text(agent, 13));
637
638            for tool in &tools {
639                let block = if let Some(corr) = correlation_map.get(&(agent.clone(), tool.clone()))
640                {
641                    self.get_correlation_block(corr.average_invocations_per_session)
642                } else {
643                    "-".to_string()
644                };
645
646                if self.show_colors {
647                    print!("{:12}", block.cyan());
648                } else {
649                    print!("{:12}", block);
650                }
651            }
652            println!();
653        }
654        println!();
655
656        // Legend
657        println!("{}", "Legend:".dimmed());
658        println!("{}", "  █████ = High usage (8+ per session)".dimmed());
659        println!("{}", "  ████  = Medium-high (6-8 per session)".dimmed());
660        println!("{}", "  ███   = Medium (4-6 per session)".dimmed());
661        println!("{}", "  ██    = Low-medium (2-4 per session)".dimmed());
662        println!("{}", "  █     = Low (1-2 per session)".dimmed());
663        println!("{}", "  -     = None".dimmed());
664        println!();
665    }
666
667    /// Get Unicode block representation for correlation strength
668    fn get_correlation_block(&self, avg_invocations: f32) -> String {
669        if avg_invocations >= 8.0 {
670            "█████".to_string()
671        } else if avg_invocations >= 6.0 {
672            "████".to_string()
673        } else if avg_invocations >= 4.0 {
674            "███".to_string()
675        } else if avg_invocations >= 2.0 {
676            "██".to_string()
677        } else if avg_invocations >= 1.0 {
678            "█".to_string()
679        } else if avg_invocations > 0.0 {
680            "▒".to_string()
681        } else {
682            "-".to_string()
683        }
684    }
685
686    /// Export tool analysis to JSON
687    pub fn tool_analysis_to_json(&self, analysis: &ToolAnalysis) -> Result<String> {
688        let json = serde_json::to_string_pretty(analysis)?;
689        Ok(json)
690    }
691
692    /// Export tool analysis to CSV
693    pub fn tool_analysis_to_csv(&self, analysis: &ToolAnalysis) -> Result<String> {
694        let mut csv_data = Vec::new();
695
696        // Add header
697        csv_data.push(vec![
698            "tool_name".to_string(),
699            "category".to_string(),
700            "count".to_string(),
701            "agents_using".to_string(),
702            "success_rate".to_string(),
703            "sessions".to_string(),
704        ]);
705
706        // Add data rows
707        for (tool_name, stat) in &analysis.tool_statistics {
708            let agents_str = stat.agents_using.join(";");
709
710            let success_rate = if stat.total_invocations > 0 {
711                #[allow(clippy::cast_precision_loss)]
712                let rate = (stat.success_count as f32 / stat.total_invocations as f32) * 100.0;
713                format!("{:.2}", rate)
714            } else {
715                "0".to_string()
716            };
717
718            csv_data.push(vec![
719                tool_name.clone(),
720                format!("{:?}", stat.category),
721                stat.total_invocations.to_string(),
722                agents_str,
723                success_rate,
724                stat.sessions.len().to_string(),
725            ]);
726        }
727
728        let mut csv_output = String::new();
729        for row in csv_data {
730            writeln!(csv_output, "{}", row.join(","))?;
731        }
732
733        Ok(csv_output)
734    }
735
736    /// Export tool analysis to Markdown
737    pub fn tool_analysis_to_markdown(&self, analysis: &ToolAnalysis) -> Result<String> {
738        let mut md = String::new();
739
740        writeln!(md, "# Tool Usage Analysis Report\n")?;
741
742        // Summary
743        writeln!(md, "## Summary\n")?;
744        writeln!(
745            md,
746            "- **Total Tool Invocations**: {}",
747            analysis.total_tool_invocations
748        )?;
749        writeln!(md, "- **Unique Tools**: {}", analysis.tool_statistics.len())?;
750        writeln!(
751            md,
752            "- **Tool Categories**: {}\n",
753            analysis.category_breakdown.len()
754        )?;
755
756        // Category breakdown
757        writeln!(md, "## Category Breakdown\n")?;
758        let mut category_rows: Vec<_> = analysis
759            .category_breakdown
760            .iter()
761            .map(|(cat, count)| (format!("{:?}", cat), *count))
762            .collect();
763        category_rows.sort_by(|a, b| b.1.cmp(&a.1));
764
765        for (category, count) in category_rows {
766            #[allow(clippy::cast_precision_loss)]
767            let percentage = (count as f32 / analysis.total_tool_invocations as f32) * 100.0;
768            writeln!(md, "- **{}**: {} ({:.1}%)", category, count, percentage)?;
769        }
770        writeln!(md)?;
771
772        // Tool frequency table
773        writeln!(md, "## Tool Frequency\n")?;
774        writeln!(
775            md,
776            "| Tool | Category | Count | Agents | Success Rate | Sessions |"
777        )?;
778        writeln!(
779            md,
780            "|------|----------|-------|--------|--------------|----------|"
781        )?;
782
783        let mut tool_list: Vec<_> = analysis.tool_statistics.iter().collect();
784        tool_list.sort_by(|a, b| b.1.total_invocations.cmp(&a.1.total_invocations));
785
786        for (tool_name, stat) in tool_list {
787            let agents_str = if stat.agents_using.is_empty() {
788                "-".to_string()
789            } else {
790                stat.agents_using.join(", ")
791            };
792
793            let success_rate = if stat.total_invocations > 0 {
794                #[allow(clippy::cast_precision_loss)]
795                let rate = (stat.success_count as f32 / stat.total_invocations as f32) * 100.0;
796                format!("{:.1}%", rate)
797            } else {
798                "-".to_string()
799            };
800
801            writeln!(
802                md,
803                "| {} | {:?} | {} | {} | {} | {} |",
804                tool_name,
805                stat.category,
806                stat.total_invocations,
807                agents_str,
808                success_rate,
809                stat.sessions.len()
810            )?;
811        }
812        writeln!(md)?;
813
814        // Agent-tool correlations
815        if !analysis.agent_tool_correlations.is_empty() {
816            writeln!(md, "## Agent-Tool Correlations\n")?;
817            writeln!(
818                md,
819                "| Agent | Tool | Usage Count | Success Rate | Avg/Session |"
820            )?;
821            writeln!(
822                md,
823                "|-------|------|-------------|--------------|-------------|"
824            )?;
825
826            for corr in &analysis.agent_tool_correlations {
827                writeln!(
828                    md,
829                    "| {} | {} | {} | {:.1}% | {:.2} |",
830                    corr.agent_type,
831                    corr.tool_name,
832                    corr.usage_count,
833                    corr.success_rate * 100.0,
834                    corr.average_invocations_per_session
835                )?;
836            }
837            writeln!(md)?;
838        }
839
840        // Tool chains
841        if !analysis.tool_chains.is_empty() {
842            writeln!(md, "## Common Tool Chains\n")?;
843            writeln!(md, "| Tools | Frequency | Success Rate | Typical Agent |")?;
844            writeln!(md, "|-------|-----------|--------------|---------------|")?;
845
846            for chain in &analysis.tool_chains {
847                let agent_str = chain.typical_agent.as_ref().map_or("-", |a| a.as_str());
848                writeln!(
849                    md,
850                    "| {} | {} | {:.1}% | {} |",
851                    chain.tools.join(" → "),
852                    chain.frequency,
853                    chain.success_rate * 100.0,
854                    agent_str
855                )?;
856            }
857            writeln!(md)?;
858        }
859
860        Ok(md)
861    }
862}
863
864impl Default for Reporter {
865    fn default() -> Self {
866        Self::new()
867    }
868}
869
870#[derive(Tabled)]
871struct FileRow {
872    #[tabled(rename = "File")]
873    file: String,
874    #[tabled(rename = "Agent")]
875    agent: String,
876    #[tabled(rename = "Contribution")]
877    contribution: String,
878    #[tabled(rename = "Confidence")]
879    confidence: String,
880    #[tabled(rename = "Ops")]
881    operations: String,
882}
883
884#[derive(Tabled)]
885#[allow(dead_code)] // Replaced by DetailedToolRow
886struct ToolRow {
887    #[tabled(rename = "Tool")]
888    tool: String,
889    #[tabled(rename = "Count")]
890    count: String,
891    #[tabled(rename = "Category")]
892    category: String,
893    #[tabled(rename = "Agents")]
894    agents: String,
895    #[tabled(rename = "Sessions")]
896    sessions: String,
897}
898
899#[derive(Tabled)]
900struct DetailedToolRow {
901    #[tabled(rename = "Tool")]
902    tool: String,
903    #[tabled(rename = "Count")]
904    count: String,
905    #[tabled(rename = "Category")]
906    category: String,
907    #[tabled(rename = "Agents")]
908    agents: String,
909    #[tabled(rename = "Success Rate")]
910    success_rate: String,
911    #[tabled(rename = "Sessions")]
912    sessions: String,
913}
914
915#[derive(Tabled)]
916struct AgentRow {
917    #[tabled(rename = "Agent")]
918    agent: String,
919    #[tabled(rename = "Invocations")]
920    invocations: String,
921    #[tabled(rename = "Duration")]
922    duration: String,
923    #[tabled(rename = "Files")]
924    files: String,
925    #[tabled(rename = "Tools")]
926    tools: String,
927}
928
929#[cfg(test)]
930mod tests {
931    use super::*;
932    use crate::models::{AgentInvocation, ToolCategory, ToolStatistics};
933
934    fn create_test_analysis() -> SessionAnalysis {
935        let timestamp = jiff::Timestamp::now();
936
937        SessionAnalysis {
938            session_id: "test-session".to_string(),
939            project_path: "/test/project".to_string(),
940            start_time: timestamp,
941            end_time: timestamp,
942            duration_ms: 5000,
943            agents: vec![AgentInvocation {
944                timestamp,
945                agent_type: "architect".to_string(),
946                task_description: "Design system".to_string(),
947                prompt: "Design the architecture".to_string(),
948                files_modified: vec![],
949                tools_used: vec![],
950                duration_ms: Some(2000),
951                parent_message_id: "msg-1".to_string(),
952                session_id: "test-session".to_string(),
953            }],
954            file_operations: vec![],
955            file_to_agents: IndexMap::new(),
956            agent_stats: IndexMap::new(),
957            collaboration_patterns: vec![],
958        }
959    }
960
961    fn create_test_tool_analysis() -> ToolAnalysis {
962        let timestamp = jiff::Timestamp::now();
963        let mut tool_statistics = IndexMap::new();
964
965        tool_statistics.insert(
966            "npm".to_string(),
967            ToolStatistics {
968                tool_name: "npm".to_string(),
969                category: ToolCategory::PackageManager,
970                total_invocations: 10,
971                agents_using: vec!["developer".to_string()],
972                success_count: 9,
973                failure_count: 1,
974                first_seen: timestamp,
975                last_seen: timestamp,
976                command_patterns: vec!["npm install".to_string()],
977                sessions: vec!["session-1".to_string()],
978            },
979        );
980
981        tool_statistics.insert(
982            "cargo".to_string(),
983            ToolStatistics {
984                tool_name: "cargo".to_string(),
985                category: ToolCategory::BuildTool,
986                total_invocations: 5,
987                agents_using: vec!["developer".to_string()],
988                success_count: 5,
989                failure_count: 0,
990                first_seen: timestamp,
991                last_seen: timestamp,
992                command_patterns: vec!["cargo build".to_string()],
993                sessions: vec!["session-1".to_string()],
994            },
995        );
996
997        let mut category_breakdown = IndexMap::new();
998        category_breakdown.insert(ToolCategory::PackageManager, 10);
999        category_breakdown.insert(ToolCategory::BuildTool, 5);
1000
1001        ToolAnalysis {
1002            session_id: "test-session".to_string(),
1003            total_tool_invocations: 15,
1004            tool_statistics,
1005            agent_tool_correlations: vec![
1006                AgentToolCorrelation {
1007                    agent_type: "developer".to_string(),
1008                    tool_name: "npm".to_string(),
1009                    usage_count: 10,
1010                    success_rate: 0.9,
1011                    average_invocations_per_session: 5.0,
1012                },
1013                AgentToolCorrelation {
1014                    agent_type: "developer".to_string(),
1015                    tool_name: "cargo".to_string(),
1016                    usage_count: 5,
1017                    success_rate: 1.0,
1018                    average_invocations_per_session: 2.5,
1019                },
1020            ],
1021            tool_chains: vec![],
1022            category_breakdown,
1023        }
1024    }
1025
1026    #[test]
1027    fn test_format_agent_icon() {
1028        let reporter = Reporter::new();
1029        assert_eq!(reporter.format_agent_icon("architect"), "🏗️");
1030        assert_eq!(reporter.format_agent_icon("developer"), "💻");
1031        assert_eq!(reporter.format_agent_icon("unknown"), "🔧");
1032    }
1033
1034    #[test]
1035    fn test_format_duration() {
1036        let reporter = Reporter::new();
1037        assert_eq!(reporter.format_duration(500), "500ms");
1038        assert_eq!(reporter.format_duration(1500), "1.5s");
1039        assert_eq!(reporter.format_duration(65000), "1.1m");
1040    }
1041
1042    #[test]
1043    fn test_truncate_path() {
1044        let reporter = Reporter::new();
1045        let long_path = "/very/long/path/to/some/file/deep/in/directory/structure/file.rs";
1046        let truncated = reporter.truncate_path(long_path, 20);
1047        assert!(truncated.len() <= 20);
1048        assert!(truncated.contains("..."));
1049    }
1050
1051    #[test]
1052    fn test_to_markdown() {
1053        let reporter = Reporter::new();
1054        let analysis = create_test_analysis();
1055        let result = reporter.to_markdown(&[analysis]);
1056        assert!(result.is_ok());
1057
1058        let markdown = result.unwrap();
1059        assert!(markdown.contains("# Claude Session Analysis Report"));
1060        assert!(markdown.contains("test-session"));
1061    }
1062
1063    #[test]
1064    fn test_to_json() {
1065        let reporter = Reporter::new();
1066        let analysis = create_test_analysis();
1067        let result = reporter.to_json(&[analysis]);
1068        assert!(result.is_ok());
1069
1070        let json = result.unwrap();
1071        assert!(json.contains("test-session"));
1072        assert!(json.contains("architect"));
1073    }
1074
1075    #[test]
1076    fn test_get_correlation_block() {
1077        let reporter = Reporter::new();
1078        assert_eq!(reporter.get_correlation_block(10.0), "█████");
1079        assert_eq!(reporter.get_correlation_block(7.0), "████");
1080        assert_eq!(reporter.get_correlation_block(5.0), "███");
1081        assert_eq!(reporter.get_correlation_block(3.0), "██");
1082        assert_eq!(reporter.get_correlation_block(1.5), "█");
1083        assert_eq!(reporter.get_correlation_block(0.5), "▒");
1084        assert_eq!(reporter.get_correlation_block(0.0), "-");
1085    }
1086
1087    #[test]
1088    fn test_tool_analysis_to_json() {
1089        let reporter = Reporter::new();
1090        let analysis = create_test_tool_analysis();
1091        let result = reporter.tool_analysis_to_json(&analysis);
1092        assert!(result.is_ok());
1093
1094        let json = result.unwrap();
1095        assert!(json.contains("npm"));
1096        assert!(json.contains("cargo"));
1097        assert!(json.contains("PackageManager"));
1098        assert!(json.contains("developer"));
1099    }
1100
1101    #[test]
1102    fn test_tool_analysis_to_csv() {
1103        let reporter = Reporter::new();
1104        let analysis = create_test_tool_analysis();
1105        let result = reporter.tool_analysis_to_csv(&analysis);
1106        assert!(result.is_ok());
1107
1108        let csv = result.unwrap();
1109        assert!(csv.contains("tool_name,category,count,agents_using,success_rate,sessions"));
1110        assert!(csv.contains("npm"));
1111        assert!(csv.contains("cargo"));
1112        assert!(csv.contains("PackageManager"));
1113    }
1114
1115    #[test]
1116    fn test_tool_analysis_to_markdown() {
1117        let reporter = Reporter::new();
1118        let analysis = create_test_tool_analysis();
1119        let result = reporter.tool_analysis_to_markdown(&analysis);
1120        assert!(result.is_ok());
1121
1122        let markdown = result.unwrap();
1123        assert!(markdown.contains("# Tool Usage Analysis Report"));
1124        assert!(markdown.contains("## Summary"));
1125        assert!(markdown.contains("## Category Breakdown"));
1126        assert!(markdown.contains("## Tool Frequency"));
1127        assert!(markdown.contains("npm"));
1128        assert!(markdown.contains("cargo"));
1129    }
1130
1131    #[test]
1132    fn test_print_tool_analysis_detailed() {
1133        let reporter = Reporter::new();
1134        let analysis = create_test_tool_analysis();
1135        let result = reporter.print_tool_analysis_detailed(&analysis, false);
1136        assert!(result.is_ok());
1137    }
1138
1139    #[test]
1140    fn test_print_tool_analysis_detailed_with_correlation() {
1141        let reporter = Reporter::new();
1142        let analysis = create_test_tool_analysis();
1143        let result = reporter.print_tool_analysis_detailed(&analysis, true);
1144        assert!(result.is_ok());
1145    }
1146
1147    #[test]
1148    fn test_print_correlation_matrix() {
1149        let reporter = Reporter::new();
1150        let correlations = vec![
1151            AgentToolCorrelation {
1152                agent_type: "developer".to_string(),
1153                tool_name: "npm".to_string(),
1154                usage_count: 10,
1155                success_rate: 0.9,
1156                average_invocations_per_session: 5.0,
1157            },
1158            AgentToolCorrelation {
1159                agent_type: "architect".to_string(),
1160                tool_name: "git".to_string(),
1161                usage_count: 3,
1162                success_rate: 1.0,
1163                average_invocations_per_session: 1.5,
1164            },
1165        ];
1166
1167        reporter.print_correlation_matrix(&correlations);
1168    }
1169
1170    #[test]
1171    fn test_print_tool_analysis_detailed_empty() {
1172        let reporter = Reporter::new();
1173        let analysis = ToolAnalysis {
1174            session_id: "test".to_string(),
1175            total_tool_invocations: 0,
1176            tool_statistics: IndexMap::new(),
1177            agent_tool_correlations: vec![],
1178            tool_chains: vec![],
1179            category_breakdown: IndexMap::new(),
1180        };
1181
1182        let result = reporter.print_tool_analysis_detailed(&analysis, false);
1183        assert!(result.is_ok());
1184    }
1185
1186    #[test]
1187    fn test_tool_analysis_csv_with_semicolons() {
1188        let reporter = Reporter::new();
1189        let timestamp = jiff::Timestamp::now();
1190        let mut tool_statistics = IndexMap::new();
1191
1192        tool_statistics.insert(
1193            "npm".to_string(),
1194            ToolStatistics {
1195                tool_name: "npm".to_string(),
1196                category: ToolCategory::PackageManager,
1197                total_invocations: 10,
1198                agents_using: vec!["developer".to_string(), "architect".to_string()],
1199                success_count: 9,
1200                failure_count: 1,
1201                first_seen: timestamp,
1202                last_seen: timestamp,
1203                command_patterns: vec![],
1204                sessions: vec!["session-1".to_string()],
1205            },
1206        );
1207
1208        let analysis = ToolAnalysis {
1209            session_id: "test".to_string(),
1210            total_tool_invocations: 10,
1211            tool_statistics,
1212            agent_tool_correlations: vec![],
1213            tool_chains: vec![],
1214            category_breakdown: IndexMap::new(),
1215        };
1216
1217        let result = reporter.tool_analysis_to_csv(&analysis);
1218        assert!(result.is_ok());
1219
1220        let csv = result.unwrap();
1221        assert!(csv.contains("developer;architect"));
1222    }
1223}