Skip to main content

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    Table, Tabled,
12    settings::{Modify, Style, Width, object::Columns},
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::new(..1)).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_key(|(ts, _, _)| *ts);
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_key(|(_, count)| std::cmp::Reverse(*count));
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_key(|(_, stat)| std::cmp::Reverse(stat.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::new(0..1)).with(Width::wrap(20)))
473            .with(Modify::new(Columns::new(3..4)).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        #[allow(clippy::unnecessary_sort_by)]
549        tool_rows.sort_by(|a, b| {
550            b.count
551                .parse::<u32>()
552                .unwrap_or(0)
553                .cmp(&a.count.parse::<u32>().unwrap_or(0))
554        });
555
556        let table = Table::new(tool_rows)
557            .with(Style::modern())
558            .with(Modify::new(Columns::new(0..1)).with(Width::wrap(20)))
559            .with(Modify::new(Columns::new(3..4)).with(Width::wrap(30)))
560            .to_string();
561        println!("{}", table);
562        println!();
563
564        // Category breakdown
565        println!("{}", "📂 Category Breakdown:".bold());
566        let mut category_rows: Vec<_> = analysis
567            .category_breakdown
568            .iter()
569            .map(|(cat, count)| (format!("{:?}", cat), *count))
570            .collect();
571        category_rows.sort_by_key(|(_, count)| std::cmp::Reverse(*count));
572
573        for (category, count) in category_rows {
574            #[allow(clippy::cast_precision_loss)]
575            let percentage = (count as f32 / analysis.total_tool_invocations as f32) * 100.0;
576            println!(
577                "  {} {} ({:.1}%)",
578                category.cyan(),
579                count.to_string().yellow(),
580                percentage
581            );
582        }
583        println!();
584
585        // Correlation matrix if requested
586        if show_correlation && !analysis.agent_tool_correlations.is_empty() {
587            self.print_correlation_matrix(&analysis.agent_tool_correlations);
588        }
589
590        Ok(())
591    }
592
593    /// Print agent-tool correlation matrix using Unicode blocks
594    pub fn print_correlation_matrix(&self, correlations: &[AgentToolCorrelation]) {
595        println!("{}", "🔗 Agent-Tool Correlation Matrix:".bold());
596        println!();
597
598        // Build matrix structure
599        let mut agents: Vec<String> = correlations
600            .iter()
601            .map(|c| c.agent_type.clone())
602            .collect::<HashSet<_>>()
603            .into_iter()
604            .collect();
605        agents.sort();
606
607        let mut tools: Vec<String> = correlations
608            .iter()
609            .map(|c| c.tool_name.clone())
610            .collect::<HashSet<_>>()
611            .into_iter()
612            .collect();
613        tools.sort();
614
615        // Build lookup map
616        let mut correlation_map: HashMap<(String, String), &AgentToolCorrelation> = HashMap::new();
617        for corr in correlations {
618            correlation_map.insert((corr.agent_type.clone(), corr.tool_name.clone()), corr);
619        }
620
621        // Print header row
622        print!("{:15}", "");
623        for tool in &tools {
624            print!("{:12}", self.truncate_text(tool, 10));
625        }
626        println!();
627
628        // Print separator
629        print!("{:15}", "");
630        for _ in &tools {
631            print!("{:12}", "─".repeat(10));
632        }
633        println!();
634
635        // Print each agent row
636        for agent in &agents {
637            print!("{:15}", self.truncate_text(agent, 13));
638
639            for tool in &tools {
640                let block = if let Some(corr) = correlation_map.get(&(agent.clone(), tool.clone()))
641                {
642                    self.get_correlation_block(corr.average_invocations_per_session)
643                } else {
644                    "-".to_string()
645                };
646
647                if self.show_colors {
648                    print!("{:12}", block.cyan());
649                } else {
650                    print!("{:12}", block);
651                }
652            }
653            println!();
654        }
655        println!();
656
657        // Legend
658        println!("{}", "Legend:".dimmed());
659        println!("{}", "  █████ = High usage (8+ per session)".dimmed());
660        println!("{}", "  ████  = Medium-high (6-8 per session)".dimmed());
661        println!("{}", "  ███   = Medium (4-6 per session)".dimmed());
662        println!("{}", "  ██    = Low-medium (2-4 per session)".dimmed());
663        println!("{}", "  █     = Low (1-2 per session)".dimmed());
664        println!("{}", "  -     = None".dimmed());
665        println!();
666    }
667
668    /// Get Unicode block representation for correlation strength
669    fn get_correlation_block(&self, avg_invocations: f32) -> String {
670        if avg_invocations >= 8.0 {
671            "█████".to_string()
672        } else if avg_invocations >= 6.0 {
673            "████".to_string()
674        } else if avg_invocations >= 4.0 {
675            "███".to_string()
676        } else if avg_invocations >= 2.0 {
677            "██".to_string()
678        } else if avg_invocations >= 1.0 {
679            "█".to_string()
680        } else if avg_invocations > 0.0 {
681            "▒".to_string()
682        } else {
683            "-".to_string()
684        }
685    }
686
687    /// Export tool analysis to JSON
688    pub fn tool_analysis_to_json(&self, analysis: &ToolAnalysis) -> Result<String> {
689        let json = serde_json::to_string_pretty(analysis)?;
690        Ok(json)
691    }
692
693    /// Export tool analysis to CSV
694    pub fn tool_analysis_to_csv(&self, analysis: &ToolAnalysis) -> Result<String> {
695        let mut csv_data = Vec::new();
696
697        // Add header
698        csv_data.push(vec![
699            "tool_name".to_string(),
700            "category".to_string(),
701            "count".to_string(),
702            "agents_using".to_string(),
703            "success_rate".to_string(),
704            "sessions".to_string(),
705        ]);
706
707        // Add data rows
708        for (tool_name, stat) in &analysis.tool_statistics {
709            let agents_str = stat.agents_using.join(";");
710
711            let success_rate = if stat.total_invocations > 0 {
712                #[allow(clippy::cast_precision_loss)]
713                let rate = (stat.success_count as f32 / stat.total_invocations as f32) * 100.0;
714                format!("{:.2}", rate)
715            } else {
716                "0".to_string()
717            };
718
719            csv_data.push(vec![
720                tool_name.clone(),
721                format!("{:?}", stat.category),
722                stat.total_invocations.to_string(),
723                agents_str,
724                success_rate,
725                stat.sessions.len().to_string(),
726            ]);
727        }
728
729        let mut csv_output = String::new();
730        for row in csv_data {
731            writeln!(csv_output, "{}", row.join(","))?;
732        }
733
734        Ok(csv_output)
735    }
736
737    /// Export tool analysis to Markdown
738    pub fn tool_analysis_to_markdown(&self, analysis: &ToolAnalysis) -> Result<String> {
739        let mut md = String::new();
740
741        writeln!(md, "# Tool Usage Analysis Report\n")?;
742
743        // Summary
744        writeln!(md, "## Summary\n")?;
745        writeln!(
746            md,
747            "- **Total Tool Invocations**: {}",
748            analysis.total_tool_invocations
749        )?;
750        writeln!(md, "- **Unique Tools**: {}", analysis.tool_statistics.len())?;
751        writeln!(
752            md,
753            "- **Tool Categories**: {}\n",
754            analysis.category_breakdown.len()
755        )?;
756
757        // Category breakdown
758        writeln!(md, "## Category Breakdown\n")?;
759        let mut category_rows: Vec<_> = analysis
760            .category_breakdown
761            .iter()
762            .map(|(cat, count)| (format!("{:?}", cat), *count))
763            .collect();
764        category_rows.sort_by_key(|(_, count)| std::cmp::Reverse(*count));
765
766        for (category, count) in category_rows {
767            #[allow(clippy::cast_precision_loss)]
768            let percentage = (count as f32 / analysis.total_tool_invocations as f32) * 100.0;
769            writeln!(md, "- **{}**: {} ({:.1}%)", category, count, percentage)?;
770        }
771        writeln!(md)?;
772
773        // Tool frequency table
774        writeln!(md, "## Tool Frequency\n")?;
775        writeln!(
776            md,
777            "| Tool | Category | Count | Agents | Success Rate | Sessions |"
778        )?;
779        writeln!(
780            md,
781            "|------|----------|-------|--------|--------------|----------|"
782        )?;
783
784        let mut tool_list: Vec<_> = analysis.tool_statistics.iter().collect();
785        tool_list.sort_by_key(|(_, stat)| std::cmp::Reverse(stat.total_invocations));
786
787        for (tool_name, stat) in tool_list {
788            let agents_str = if stat.agents_using.is_empty() {
789                "-".to_string()
790            } else {
791                stat.agents_using.join(", ")
792            };
793
794            let success_rate = if stat.total_invocations > 0 {
795                #[allow(clippy::cast_precision_loss)]
796                let rate = (stat.success_count as f32 / stat.total_invocations as f32) * 100.0;
797                format!("{:.1}%", rate)
798            } else {
799                "-".to_string()
800            };
801
802            writeln!(
803                md,
804                "| {} | {:?} | {} | {} | {} | {} |",
805                tool_name,
806                stat.category,
807                stat.total_invocations,
808                agents_str,
809                success_rate,
810                stat.sessions.len()
811            )?;
812        }
813        writeln!(md)?;
814
815        // Agent-tool correlations
816        if !analysis.agent_tool_correlations.is_empty() {
817            writeln!(md, "## Agent-Tool Correlations\n")?;
818            writeln!(
819                md,
820                "| Agent | Tool | Usage Count | Success Rate | Avg/Session |"
821            )?;
822            writeln!(
823                md,
824                "|-------|------|-------------|--------------|-------------|"
825            )?;
826
827            for corr in &analysis.agent_tool_correlations {
828                writeln!(
829                    md,
830                    "| {} | {} | {} | {:.1}% | {:.2} |",
831                    corr.agent_type,
832                    corr.tool_name,
833                    corr.usage_count,
834                    corr.success_rate * 100.0,
835                    corr.average_invocations_per_session
836                )?;
837            }
838            writeln!(md)?;
839        }
840
841        // Tool chains
842        if !analysis.tool_chains.is_empty() {
843            writeln!(md, "## Common Tool Chains\n")?;
844            writeln!(md, "| Tools | Frequency | Success Rate | Typical Agent |")?;
845            writeln!(md, "|-------|-----------|--------------|---------------|")?;
846
847            for chain in &analysis.tool_chains {
848                let agent_str = chain.typical_agent.as_ref().map_or("-", |a| a.as_str());
849                writeln!(
850                    md,
851                    "| {} | {} | {:.1}% | {} |",
852                    chain.tools.join(" → "),
853                    chain.frequency,
854                    chain.success_rate * 100.0,
855                    agent_str
856                )?;
857            }
858            writeln!(md)?;
859        }
860
861        Ok(md)
862    }
863}
864
865impl Default for Reporter {
866    fn default() -> Self {
867        Self::new()
868    }
869}
870
871#[derive(Tabled)]
872struct FileRow {
873    #[tabled(rename = "File")]
874    file: String,
875    #[tabled(rename = "Agent")]
876    agent: String,
877    #[tabled(rename = "Contribution")]
878    contribution: String,
879    #[tabled(rename = "Confidence")]
880    confidence: String,
881    #[tabled(rename = "Ops")]
882    operations: String,
883}
884
885#[derive(Tabled)]
886#[allow(dead_code)] // Replaced by DetailedToolRow
887struct ToolRow {
888    #[tabled(rename = "Tool")]
889    tool: String,
890    #[tabled(rename = "Count")]
891    count: String,
892    #[tabled(rename = "Category")]
893    category: String,
894    #[tabled(rename = "Agents")]
895    agents: String,
896    #[tabled(rename = "Sessions")]
897    sessions: String,
898}
899
900#[derive(Tabled)]
901struct DetailedToolRow {
902    #[tabled(rename = "Tool")]
903    tool: String,
904    #[tabled(rename = "Count")]
905    count: String,
906    #[tabled(rename = "Category")]
907    category: String,
908    #[tabled(rename = "Agents")]
909    agents: String,
910    #[tabled(rename = "Success Rate")]
911    success_rate: String,
912    #[tabled(rename = "Sessions")]
913    sessions: String,
914}
915
916#[derive(Tabled)]
917struct AgentRow {
918    #[tabled(rename = "Agent")]
919    agent: String,
920    #[tabled(rename = "Invocations")]
921    invocations: String,
922    #[tabled(rename = "Duration")]
923    duration: String,
924    #[tabled(rename = "Files")]
925    files: String,
926    #[tabled(rename = "Tools")]
927    tools: String,
928}
929
930#[cfg(test)]
931mod tests {
932    use super::*;
933    use crate::models::{AgentInvocation, ToolCategory, ToolStatistics};
934
935    fn create_test_analysis() -> SessionAnalysis {
936        let timestamp = jiff::Timestamp::now();
937
938        SessionAnalysis {
939            session_id: "test-session".to_string(),
940            project_path: "/test/project".to_string(),
941            start_time: timestamp,
942            end_time: timestamp,
943            duration_ms: 5000,
944            agents: vec![AgentInvocation {
945                timestamp,
946                agent_type: "architect".to_string(),
947                task_description: "Design system".to_string(),
948                prompt: "Design the architecture".to_string(),
949                files_modified: vec![],
950                tools_used: vec![],
951                duration_ms: Some(2000),
952                parent_message_id: "msg-1".to_string(),
953                session_id: "test-session".to_string(),
954            }],
955            file_operations: vec![],
956            file_to_agents: IndexMap::new(),
957            agent_stats: IndexMap::new(),
958            collaboration_patterns: vec![],
959        }
960    }
961
962    fn create_test_tool_analysis() -> ToolAnalysis {
963        let timestamp = jiff::Timestamp::now();
964        let mut tool_statistics = IndexMap::new();
965
966        tool_statistics.insert(
967            "npm".to_string(),
968            ToolStatistics {
969                tool_name: "npm".to_string(),
970                category: ToolCategory::PackageManager,
971                total_invocations: 10,
972                agents_using: vec!["developer".to_string()],
973                success_count: 9,
974                failure_count: 1,
975                first_seen: timestamp,
976                last_seen: timestamp,
977                command_patterns: vec!["npm install".to_string()],
978                sessions: vec!["session-1".to_string()],
979            },
980        );
981
982        tool_statistics.insert(
983            "cargo".to_string(),
984            ToolStatistics {
985                tool_name: "cargo".to_string(),
986                category: ToolCategory::BuildTool,
987                total_invocations: 5,
988                agents_using: vec!["developer".to_string()],
989                success_count: 5,
990                failure_count: 0,
991                first_seen: timestamp,
992                last_seen: timestamp,
993                command_patterns: vec!["cargo build".to_string()],
994                sessions: vec!["session-1".to_string()],
995            },
996        );
997
998        let mut category_breakdown = IndexMap::new();
999        category_breakdown.insert(ToolCategory::PackageManager, 10);
1000        category_breakdown.insert(ToolCategory::BuildTool, 5);
1001
1002        ToolAnalysis {
1003            session_id: "test-session".to_string(),
1004            total_tool_invocations: 15,
1005            tool_statistics,
1006            agent_tool_correlations: vec![
1007                AgentToolCorrelation {
1008                    agent_type: "developer".to_string(),
1009                    tool_name: "npm".to_string(),
1010                    usage_count: 10,
1011                    success_rate: 0.9,
1012                    average_invocations_per_session: 5.0,
1013                },
1014                AgentToolCorrelation {
1015                    agent_type: "developer".to_string(),
1016                    tool_name: "cargo".to_string(),
1017                    usage_count: 5,
1018                    success_rate: 1.0,
1019                    average_invocations_per_session: 2.5,
1020                },
1021            ],
1022            tool_chains: vec![],
1023            category_breakdown,
1024        }
1025    }
1026
1027    #[test]
1028    fn test_format_agent_icon() {
1029        let reporter = Reporter::new();
1030        assert_eq!(reporter.format_agent_icon("architect"), "🏗️");
1031        assert_eq!(reporter.format_agent_icon("developer"), "💻");
1032        assert_eq!(reporter.format_agent_icon("unknown"), "🔧");
1033    }
1034
1035    #[test]
1036    fn test_format_duration() {
1037        let reporter = Reporter::new();
1038        assert_eq!(reporter.format_duration(500), "500ms");
1039        assert_eq!(reporter.format_duration(1500), "1.5s");
1040        assert_eq!(reporter.format_duration(65000), "1.1m");
1041    }
1042
1043    #[test]
1044    fn test_truncate_path() {
1045        let reporter = Reporter::new();
1046        let long_path = "/very/long/path/to/some/file/deep/in/directory/structure/file.rs";
1047        let truncated = reporter.truncate_path(long_path, 20);
1048        assert!(truncated.len() <= 20);
1049        assert!(truncated.contains("..."));
1050    }
1051
1052    #[test]
1053    fn test_to_markdown() {
1054        let reporter = Reporter::new();
1055        let analysis = create_test_analysis();
1056        let result = reporter.to_markdown(&[analysis]);
1057        assert!(result.is_ok());
1058
1059        let markdown = result.unwrap();
1060        assert!(markdown.contains("# Claude Session Analysis Report"));
1061        assert!(markdown.contains("test-session"));
1062    }
1063
1064    #[test]
1065    fn test_to_json() {
1066        let reporter = Reporter::new();
1067        let analysis = create_test_analysis();
1068        let result = reporter.to_json(&[analysis]);
1069        assert!(result.is_ok());
1070
1071        let json = result.unwrap();
1072        assert!(json.contains("test-session"));
1073        assert!(json.contains("architect"));
1074    }
1075
1076    #[test]
1077    fn test_get_correlation_block() {
1078        let reporter = Reporter::new();
1079        assert_eq!(reporter.get_correlation_block(10.0), "█████");
1080        assert_eq!(reporter.get_correlation_block(7.0), "████");
1081        assert_eq!(reporter.get_correlation_block(5.0), "███");
1082        assert_eq!(reporter.get_correlation_block(3.0), "██");
1083        assert_eq!(reporter.get_correlation_block(1.5), "█");
1084        assert_eq!(reporter.get_correlation_block(0.5), "▒");
1085        assert_eq!(reporter.get_correlation_block(0.0), "-");
1086    }
1087
1088    #[test]
1089    fn test_tool_analysis_to_json() {
1090        let reporter = Reporter::new();
1091        let analysis = create_test_tool_analysis();
1092        let result = reporter.tool_analysis_to_json(&analysis);
1093        assert!(result.is_ok());
1094
1095        let json = result.unwrap();
1096        assert!(json.contains("npm"));
1097        assert!(json.contains("cargo"));
1098        assert!(json.contains("PackageManager"));
1099        assert!(json.contains("developer"));
1100    }
1101
1102    #[test]
1103    fn test_tool_analysis_to_csv() {
1104        let reporter = Reporter::new();
1105        let analysis = create_test_tool_analysis();
1106        let result = reporter.tool_analysis_to_csv(&analysis);
1107        assert!(result.is_ok());
1108
1109        let csv = result.unwrap();
1110        assert!(csv.contains("tool_name,category,count,agents_using,success_rate,sessions"));
1111        assert!(csv.contains("npm"));
1112        assert!(csv.contains("cargo"));
1113        assert!(csv.contains("PackageManager"));
1114    }
1115
1116    #[test]
1117    fn test_tool_analysis_to_markdown() {
1118        let reporter = Reporter::new();
1119        let analysis = create_test_tool_analysis();
1120        let result = reporter.tool_analysis_to_markdown(&analysis);
1121        assert!(result.is_ok());
1122
1123        let markdown = result.unwrap();
1124        assert!(markdown.contains("# Tool Usage Analysis Report"));
1125        assert!(markdown.contains("## Summary"));
1126        assert!(markdown.contains("## Category Breakdown"));
1127        assert!(markdown.contains("## Tool Frequency"));
1128        assert!(markdown.contains("npm"));
1129        assert!(markdown.contains("cargo"));
1130    }
1131
1132    #[test]
1133    fn test_print_tool_analysis_detailed() {
1134        let reporter = Reporter::new();
1135        let analysis = create_test_tool_analysis();
1136        let result = reporter.print_tool_analysis_detailed(&analysis, false);
1137        assert!(result.is_ok());
1138    }
1139
1140    #[test]
1141    fn test_print_tool_analysis_detailed_with_correlation() {
1142        let reporter = Reporter::new();
1143        let analysis = create_test_tool_analysis();
1144        let result = reporter.print_tool_analysis_detailed(&analysis, true);
1145        assert!(result.is_ok());
1146    }
1147
1148    #[test]
1149    fn test_print_correlation_matrix() {
1150        let reporter = Reporter::new();
1151        let correlations = vec![
1152            AgentToolCorrelation {
1153                agent_type: "developer".to_string(),
1154                tool_name: "npm".to_string(),
1155                usage_count: 10,
1156                success_rate: 0.9,
1157                average_invocations_per_session: 5.0,
1158            },
1159            AgentToolCorrelation {
1160                agent_type: "architect".to_string(),
1161                tool_name: "git".to_string(),
1162                usage_count: 3,
1163                success_rate: 1.0,
1164                average_invocations_per_session: 1.5,
1165            },
1166        ];
1167
1168        reporter.print_correlation_matrix(&correlations);
1169    }
1170
1171    #[test]
1172    fn test_print_tool_analysis_detailed_empty() {
1173        let reporter = Reporter::new();
1174        let analysis = ToolAnalysis {
1175            session_id: "test".to_string(),
1176            total_tool_invocations: 0,
1177            tool_statistics: IndexMap::new(),
1178            agent_tool_correlations: vec![],
1179            tool_chains: vec![],
1180            category_breakdown: IndexMap::new(),
1181        };
1182
1183        let result = reporter.print_tool_analysis_detailed(&analysis, false);
1184        assert!(result.is_ok());
1185    }
1186
1187    #[test]
1188    fn test_tool_analysis_csv_with_semicolons() {
1189        let reporter = Reporter::new();
1190        let timestamp = jiff::Timestamp::now();
1191        let mut tool_statistics = IndexMap::new();
1192
1193        tool_statistics.insert(
1194            "npm".to_string(),
1195            ToolStatistics {
1196                tool_name: "npm".to_string(),
1197                category: ToolCategory::PackageManager,
1198                total_invocations: 10,
1199                agents_using: vec!["developer".to_string(), "architect".to_string()],
1200                success_count: 9,
1201                failure_count: 1,
1202                first_seen: timestamp,
1203                last_seen: timestamp,
1204                command_patterns: vec![],
1205                sessions: vec!["session-1".to_string()],
1206            },
1207        );
1208
1209        let analysis = ToolAnalysis {
1210            session_id: "test".to_string(),
1211            total_tool_invocations: 10,
1212            tool_statistics,
1213            agent_tool_correlations: vec![],
1214            tool_chains: vec![],
1215            category_breakdown: IndexMap::new(),
1216        };
1217
1218        let result = reporter.tool_analysis_to_csv(&analysis);
1219        assert!(result.is_ok());
1220
1221        let csv = result.unwrap();
1222        assert!(csv.contains("developer;architect"));
1223    }
1224}