1use crate::models::{
2 AgentAttribution, AgentInvocation, AgentStatistics, AgentToolCorrelation, AnalyzerConfig,
3 CollaborationPattern, FileOperation, SessionAnalysis, ToolCategory, ToolInvocation,
4 ToolStatistics,
5};
6use crate::parser::SessionParser;
7use anyhow::Result;
8use indexmap::IndexMap;
9use rayon::prelude::*;
10use std::collections::{HashMap, HashSet};
11use std::path::Path;
12use tracing::{debug, info};
13
14pub struct Analyzer {
15 parsers: Vec<SessionParser>,
16 config: AnalyzerConfig,
17}
18
19impl Analyzer {
20 pub fn new<P: AsRef<Path>>(path: P) -> Result<Self> {
26 let path = path.as_ref();
27 let parsers = if path.is_file() {
28 vec![SessionParser::from_file(path)?]
29 } else if path.is_dir() {
30 SessionParser::from_directory(path)?
31 } else {
32 return Err(anyhow::anyhow!("Path does not exist: {}", path.display()));
33 };
34
35 Ok(Self {
36 parsers,
37 config: AnalyzerConfig::default(),
38 })
39 }
40
41 pub fn from_default_location() -> Result<Self> {
47 let parsers = SessionParser::from_default_location()?;
48 Ok(Self {
49 parsers,
50 config: AnalyzerConfig::default(),
51 })
52 }
53
54 #[allow(dead_code)]
57 #[must_use]
58 pub fn with_config(mut self, config: AnalyzerConfig) -> Self {
59 self.config = config;
60 self
61 }
62
63 pub fn analyze(&self, target_file: Option<&str>) -> Result<Vec<SessionAnalysis>> {
69 info!("Analyzing {} session(s)", self.parsers.len());
70
71 let analyses: Result<Vec<_>> = self
72 .parsers
73 .par_iter()
74 .filter_map(|parser| {
75 match self.analyze_session(parser, target_file) {
76 Ok(analysis) => {
77 if let Some(_target) = target_file {
79 if analysis.file_operations.is_empty() {
80 return None; }
82 }
83 Some(Ok(analysis))
84 }
85 Err(e) => Some(Err(e)),
86 }
87 })
88 .collect();
89
90 analyses
91 }
92
93 fn analyze_session(
95 &self,
96 parser: &SessionParser,
97 target_file: Option<&str>,
98 ) -> Result<SessionAnalysis> {
99 let (session_id, project_path, start_time, end_time) = parser.get_session_info();
100
101 debug!("Analyzing session: {}", session_id);
102
103 let mut agents = parser.extract_agent_invocations();
105 let mut file_operations = parser.extract_file_operations();
106
107 agents.sort_by_key(|a| a.timestamp);
109
110 self.set_agent_context(&mut file_operations, &agents, parser);
112
113 Self::calculate_agent_durations(&mut agents);
115
116 if let Some(target) = target_file {
118 file_operations.retain(|op| op.file_path.contains(target));
119
120 let relevant_agent_contexts: HashSet<&str> = file_operations
122 .iter()
123 .filter_map(|op| op.agent_context.as_deref())
124 .collect();
125
126 agents.retain(|agent| relevant_agent_contexts.contains(agent.agent_type.as_str()));
127 }
128
129 let file_to_agents = self.build_file_attributions(&file_operations, &agents);
131
132 let agent_stats = Self::calculate_agent_statistics(&agents, &file_operations);
134
135 let collaboration_patterns = self.detect_collaboration_patterns(&agents);
137
138 let duration_ms = if let (Some(start), Some(end)) = (start_time, end_time) {
139 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
140 {
141 (end - start).total(jiff::Unit::Millisecond)? as u64
142 }
143 } else {
144 0
145 };
146
147 Ok(SessionAnalysis {
148 session_id,
149 project_path,
150 start_time: start_time.unwrap_or_else(jiff::Timestamp::now),
151 end_time: end_time.unwrap_or_else(jiff::Timestamp::now),
152 duration_ms,
153 agents,
154 file_operations,
155 file_to_agents,
156 agent_stats,
157 collaboration_patterns,
158 })
159 }
160
161 fn set_agent_context(
164 &self,
165 file_operations: &mut [FileOperation],
166 agents: &[AgentInvocation],
167 parser: &SessionParser,
168 ) {
169 debug_assert!(agents.windows(2).all(|w| w[0].timestamp <= w[1].timestamp));
172
173 for file_op in file_operations.iter_mut() {
174 if let Some(agent) = parser.find_active_agent(&file_op.message_id) {
176 file_op.agent_context = Some(agent);
177 continue;
178 }
179
180 let agent_idx = match agents.binary_search_by_key(&file_op.timestamp, |a| a.timestamp) {
183 Ok(idx) => Some(idx), Err(idx) => {
185 if idx > 0 {
186 Some(idx - 1) } else {
188 None }
190 }
191 };
192
193 if let Some(idx) = agent_idx {
194 let agent = &agents[idx];
195 let time_diff = file_op.timestamp - agent.timestamp;
196 let time_diff_ms = time_diff.total(jiff::Unit::Millisecond).unwrap_or(0.0);
197 let window_ms = self.config.file_attribution_window_ms;
198
199 #[allow(clippy::cast_precision_loss)]
200 if time_diff_ms <= (window_ms as f64) {
201 file_op.agent_context = Some(agent.agent_type.clone());
202 }
203 }
204 }
205 }
206
207 fn calculate_agent_durations(agents: &mut [AgentInvocation]) {
209 agents.sort_by_key(|a| a.timestamp);
211
212 for i in 0..agents.len() {
213 if i + 1 < agents.len() {
214 let duration = agents[i + 1].timestamp - agents[i].timestamp;
215 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
216 {
217 agents[i].duration_ms =
218 Some(duration.total(jiff::Unit::Millisecond).unwrap_or(0.0) as u64);
219 }
220 }
221 }
222 }
223
224 fn build_file_attributions(
226 &self,
227 file_operations: &[FileOperation],
228 _agents: &[AgentInvocation],
229 ) -> IndexMap<String, Vec<AgentAttribution>> {
230 let mut file_to_agents: IndexMap<String, Vec<AgentAttribution>> = IndexMap::new();
231
232 let mut file_groups: HashMap<String, Vec<&FileOperation>> = HashMap::new();
234 for op in file_operations {
235 if self.should_exclude_file(&op.file_path) {
237 continue;
238 }
239
240 file_groups
241 .entry(op.file_path.clone())
242 .or_default()
243 .push(op);
244 }
245
246 for (file_path, ops) in file_groups {
248 let mut agent_contributions: HashMap<String, AgentContribution> = HashMap::new();
249
250 for op in ops {
251 if let Some(agent_type) = &op.agent_context {
252 let contribution = agent_contributions
253 .entry(agent_type.clone())
254 .or_insert_with(|| AgentContribution {
255 operations: Vec::new(),
256 first_interaction: op.timestamp,
257 last_interaction: op.timestamp,
258 });
259
260 contribution.operations.push(format!("{:?}", op.operation));
261 if op.timestamp < contribution.first_interaction {
262 contribution.first_interaction = op.timestamp;
263 }
264 if op.timestamp > contribution.last_interaction {
265 contribution.last_interaction = op.timestamp;
266 }
267 }
268 }
269
270 #[allow(clippy::cast_precision_loss)]
272 let total_ops = agent_contributions
273 .values()
274 .map(|c| c.operations.len())
275 .sum::<usize>() as f32;
276
277 if total_ops > 0.0 {
278 let attributions: Vec<AgentAttribution> = agent_contributions
279 .into_iter()
280 .map(|(agent_type, contribution)| {
281 #[allow(clippy::cast_precision_loss)]
282 let contribution_percent =
283 (contribution.operations.len() as f32 / total_ops) * 100.0;
284 let confidence_score = Self::calculate_confidence_score(
285 &contribution.operations,
286 contribution_percent,
287 );
288
289 AgentAttribution {
290 agent_type,
291 contribution_percent,
292 confidence_score,
293 operations: contribution.operations,
294 first_interaction: contribution.first_interaction,
295 last_interaction: contribution.last_interaction,
296 }
297 })
298 .collect();
299
300 file_to_agents.insert(file_path, attributions);
301 }
302 }
303
304 file_to_agents
305 }
306
307 fn calculate_agent_statistics(
309 agents: &[AgentInvocation],
310 file_operations: &[FileOperation],
311 ) -> IndexMap<String, AgentStatistics> {
312 let mut stats: IndexMap<String, AgentStatistics> = IndexMap::new();
313
314 let mut agent_groups: HashMap<String, Vec<&AgentInvocation>> = HashMap::new();
316 for agent in agents {
317 agent_groups
318 .entry(agent.agent_type.clone())
319 .or_default()
320 .push(agent);
321 }
322
323 for (agent_type, agent_list) in agent_groups {
325 #[allow(clippy::cast_possible_truncation)]
326 let total_invocations = agent_list.len() as u32;
327 let total_duration_ms = agent_list.iter().filter_map(|a| a.duration_ms).sum::<u64>();
328
329 #[allow(clippy::cast_possible_truncation)]
330 let files_touched = file_operations
331 .iter()
332 .filter(|op| op.agent_context.as_ref() == Some(&agent_type))
333 .map(|op| &op.file_path)
334 .collect::<HashSet<_>>()
335 .len() as u32;
336
337 let tools_used = file_operations
338 .iter()
339 .filter(|op| op.agent_context.as_ref() == Some(&agent_type))
340 .map(|op| format!("{:?}", op.operation))
341 .collect::<HashSet<_>>()
342 .into_iter()
343 .collect();
344
345 let first_seen = agent_list
346 .iter()
347 .map(|a| a.timestamp)
348 .min()
349 .unwrap_or_else(jiff::Timestamp::now);
350 let last_seen = agent_list
351 .iter()
352 .map(|a| a.timestamp)
353 .max()
354 .unwrap_or_else(jiff::Timestamp::now);
355
356 stats.insert(
357 agent_type.clone(),
358 AgentStatistics {
359 agent_type,
360 total_invocations,
361 total_duration_ms,
362 files_touched,
363 tools_used,
364 first_seen,
365 last_seen,
366 },
367 );
368 }
369
370 stats
371 }
372
373 fn detect_collaboration_patterns(
375 &self,
376 agents: &[AgentInvocation],
377 ) -> Vec<CollaborationPattern> {
378 let mut patterns = Vec::new();
379
380 let sequential_pattern = Self::detect_sequential_pattern(agents);
382 if let Some(pattern) = sequential_pattern {
383 patterns.push(pattern);
384 }
385
386 let parallel_pattern = Self::detect_parallel_pattern(agents);
388 if let Some(pattern) = parallel_pattern {
389 patterns.push(pattern);
390 }
391
392 patterns
393 }
394
395 fn detect_sequential_pattern(agents: &[AgentInvocation]) -> Option<CollaborationPattern> {
397 let common_sequences = vec![
398 vec!["architect", "developer", "test-writer-fixer"],
399 vec!["architect", "backend-architect", "developer"],
400 vec!["rapid-prototyper", "developer", "technical-writer"],
401 ];
402
403 for sequence in common_sequences {
404 if Self::matches_sequence(agents, &sequence) {
405 return Some(CollaborationPattern {
406 pattern_type: "Sequential".to_string(),
407 agents: sequence.iter().map(|s| (*s).to_string()).collect(),
408 description: format!("Sequential workflow: {}", sequence.join(" → ")),
409 frequency: 1,
410 confidence: 0.8,
411 });
412 }
413 }
414
415 None
416 }
417
418 fn detect_parallel_pattern(agents: &[AgentInvocation]) -> Option<CollaborationPattern> {
420 let window_ms = 300_000; let mut time_groups: Vec<Vec<&AgentInvocation>> = Vec::new();
423
424 for agent in agents {
425 let mut found_group = false;
426 for group in &mut time_groups {
427 if let Some(first) = group.first() {
428 let time_diff = (agent.timestamp - first.timestamp)
429 .total(jiff::Unit::Millisecond)
430 .unwrap_or(0.0)
431 .abs();
432 if time_diff <= f64::from(window_ms) {
433 group.push(agent);
434 found_group = true;
435 break;
436 }
437 }
438 }
439 if !found_group {
440 time_groups.push(vec![agent]);
441 }
442 }
443
444 for group in time_groups {
446 let unique_agents: HashSet<&str> =
447 group.iter().map(|a| a.agent_type.as_str()).collect();
448
449 if unique_agents.len() >= 2 {
450 return Some(CollaborationPattern {
451 pattern_type: "Parallel".to_string(),
452 agents: unique_agents.iter().map(|s| (*s).to_string()).collect(),
453 description: format!(
454 "Parallel collaboration: {}",
455 unique_agents
456 .iter()
457 .map(|s| (*s).to_string())
458 .collect::<Vec<_>>()
459 .join(" + ")
460 ),
461 frequency: 1,
462 confidence: 0.7,
463 });
464 }
465 }
466
467 None
468 }
469
470 fn matches_sequence(agents: &[AgentInvocation], sequence: &[&str]) -> bool {
472 let agent_types: Vec<&str> = agents.iter().map(|a| a.agent_type.as_str()).collect();
473
474 for window in agent_types.windows(sequence.len()) {
476 if window == sequence {
477 return true;
478 }
479 }
480
481 false
482 }
483
484 fn calculate_confidence_score(operations: &[String], contribution_percent: f32) -> f32 {
486 let mut confidence = 0.5; #[allow(clippy::cast_precision_loss)]
490 {
491 confidence += (operations.len() as f32 * 0.1).min(0.3);
492 }
493
494 confidence += (contribution_percent / 100.0) * 0.4;
496
497 #[allow(clippy::cast_precision_loss)]
499 let write_ops = operations
500 .iter()
501 .filter(|op| matches!(op.as_str(), "Write" | "Edit" | "MultiEdit"))
502 .count() as f32;
503 #[allow(clippy::cast_precision_loss)]
504 let total_ops = operations.len() as f32;
505
506 if total_ops > 0.0 {
507 let write_ratio = write_ops / total_ops;
508 confidence += write_ratio * 0.2;
509 }
510
511 confidence.min(1.0)
512 }
513
514 fn should_exclude_file(&self, file_path: &str) -> bool {
516 for pattern in &self.config.exclude_patterns {
517 if file_path.contains(pattern) {
518 return true;
519 }
520 }
521 false
522 }
523
524 pub fn get_summary_stats(&self) -> Result<SummaryStats> {
530 let analyses = self.analyze(None)?;
531
532 let total_sessions = analyses.len();
533 let total_agents = analyses.iter().map(|a| a.agents.len()).sum::<usize>();
534 let total_files = analyses
535 .iter()
536 .map(|a| a.file_to_agents.len())
537 .sum::<usize>();
538
539 let agent_types: HashSet<String> = analyses
540 .iter()
541 .flat_map(|a| a.agents.iter().map(|ag| ag.agent_type.clone()))
542 .collect();
543
544 Ok(SummaryStats {
545 total_sessions,
546 total_agents,
547 total_files,
548 unique_agent_types: agent_types.len(),
549 most_active_agents: Self::get_most_active_agents(&analyses),
550 })
551 }
552
553 fn get_most_active_agents(analyses: &[SessionAnalysis]) -> Vec<(String, u32)> {
555 let mut agent_counts: HashMap<String, u32> = HashMap::new();
556
557 for analysis in analyses {
558 for agent in &analysis.agents {
559 *agent_counts.entry(agent.agent_type.clone()).or_insert(0) += 1;
560 }
561 }
562
563 let mut sorted: Vec<_> = agent_counts.into_iter().collect();
564 sorted.sort_by_key(|(_, count)| std::cmp::Reverse(*count));
565 sorted.into_iter().take(10).collect()
566 }
567
568 #[must_use]
571 pub fn calculate_agent_tool_correlations(
572 &self,
573 tool_invocations: &[ToolInvocation],
574 ) -> Vec<AgentToolCorrelation> {
575 let mut agent_tool_map: HashMap<(String, String), AgentToolData> = HashMap::new();
577
578 for invocation in tool_invocations {
579 if let Some(agent) = &invocation.agent_context {
580 let key = (agent.clone(), invocation.tool_name.clone());
581 let data = agent_tool_map.entry(key).or_insert_with(|| AgentToolData {
582 usage_count: 0,
583 success_count: 0,
584 failure_count: 0,
585 session_count: HashSet::new(),
586 });
587
588 data.usage_count += 1;
589 data.session_count.insert(invocation.session_id.clone());
590
591 if let Some(exit_code) = invocation.exit_code {
592 if exit_code == 0 {
593 data.success_count += 1;
594 } else {
595 data.failure_count += 1;
596 }
597 }
598 }
599 }
600
601 let mut agent_totals: HashMap<String, u32> = HashMap::new();
603 for ((agent, _), data) in &agent_tool_map {
604 *agent_totals.entry(agent.clone()).or_insert(0) += data.usage_count;
605 }
606
607 let mut correlations: Vec<AgentToolCorrelation> = agent_tool_map
609 .into_iter()
610 .map(|((agent_type, tool_name), data)| {
611 let total_attempts = data.success_count + data.failure_count;
612 #[allow(clippy::cast_precision_loss)]
613 let success_rate = if total_attempts > 0 {
614 (data.success_count as f32) / (total_attempts as f32)
615 } else {
616 0.0
617 };
618
619 #[allow(clippy::cast_precision_loss)]
620 let average_invocations_per_session = if !data.session_count.is_empty() {
621 (data.usage_count as f32) / (data.session_count.len() as f32)
622 } else {
623 0.0
624 };
625
626 AgentToolCorrelation {
627 agent_type,
628 tool_name,
629 usage_count: data.usage_count,
630 success_rate,
631 average_invocations_per_session,
632 }
633 })
634 .collect();
635
636 correlations.sort_by_key(|c| std::cmp::Reverse(c.usage_count));
638
639 correlations
640 }
641
642 #[must_use]
644 pub fn calculate_tool_statistics(
645 &self,
646 tool_invocations: &[ToolInvocation],
647 ) -> IndexMap<String, ToolStatistics> {
648 let mut tool_map: HashMap<String, ToolStatsData> = HashMap::new();
649
650 for invocation in tool_invocations {
651 let data = tool_map
652 .entry(invocation.tool_name.clone())
653 .or_insert_with(|| ToolStatsData {
654 category: invocation.tool_category.clone(),
655 total_invocations: 0,
656 agents_using: HashSet::new(),
657 success_count: 0,
658 failure_count: 0,
659 first_seen: invocation.timestamp,
660 last_seen: invocation.timestamp,
661 command_patterns: HashSet::new(),
662 sessions: HashSet::new(),
663 });
664
665 data.total_invocations += 1;
666 data.sessions.insert(invocation.session_id.clone());
667
668 if let Some(agent) = &invocation.agent_context {
669 data.agents_using.insert(agent.clone());
670 }
671
672 if let Some(exit_code) = invocation.exit_code {
673 if exit_code == 0 {
674 data.success_count += 1;
675 } else {
676 data.failure_count += 1;
677 }
678 }
679
680 if invocation.timestamp < data.first_seen {
681 data.first_seen = invocation.timestamp;
682 }
683 if invocation.timestamp > data.last_seen {
684 data.last_seen = invocation.timestamp;
685 }
686
687 let pattern = if invocation.command_line.len() > 100 {
689 invocation.command_line[..100].to_string()
690 } else {
691 invocation.command_line.clone()
692 };
693 data.command_patterns.insert(pattern);
694 }
695
696 let mut stats: IndexMap<String, ToolStatistics> = tool_map
698 .into_iter()
699 .map(|(tool_name, data)| {
700 #[allow(clippy::cast_possible_truncation)]
701 let stats = ToolStatistics {
702 tool_name: tool_name.clone(),
703 category: data.category,
704 total_invocations: data.total_invocations,
705 agents_using: data.agents_using.into_iter().collect(),
706 success_count: data.success_count,
707 failure_count: data.failure_count,
708 first_seen: data.first_seen,
709 last_seen: data.last_seen,
710 command_patterns: data.command_patterns.into_iter().take(10).collect(),
711 sessions: data.sessions.into_iter().collect(),
712 };
713 (tool_name, stats)
714 })
715 .collect();
716
717 #[allow(clippy::unnecessary_sort_by)]
719 stats.sort_by(|_, v1, _, v2| v2.total_invocations.cmp(&v1.total_invocations));
720
721 stats
722 }
723
724 #[must_use]
726 pub fn calculate_category_breakdown(
727 &self,
728 tool_invocations: &[ToolInvocation],
729 ) -> IndexMap<ToolCategory, u32> {
730 let mut category_counts: HashMap<ToolCategory, u32> = HashMap::new();
731
732 for invocation in tool_invocations {
733 *category_counts
734 .entry(invocation.tool_category.clone())
735 .or_insert(0) += 1;
736 }
737
738 let mut breakdown: IndexMap<ToolCategory, u32> = category_counts.into_iter().collect();
740 #[allow(clippy::unnecessary_sort_by)]
741 breakdown.sort_by(|_, v1, _, v2| v2.cmp(v1));
742
743 breakdown
744 }
745
746 #[must_use]
762 #[allow(dead_code)] pub fn detect_tool_chains(
764 &self,
765 tool_invocations: &[ToolInvocation],
766 ) -> Vec<crate::models::ToolChain> {
767 use crate::models::ToolChain;
768
769 let mut session_tools: HashMap<String, Vec<&ToolInvocation>> = HashMap::new();
771 for invocation in tool_invocations {
772 session_tools
773 .entry(invocation.session_id.clone())
774 .or_default()
775 .push(invocation);
776 }
777
778 let mut sequence_map: HashMap<Vec<String>, SequenceData> = HashMap::new();
780
781 const MAX_TIME_BETWEEN_TOOLS_MS: u64 = 3_600_000;
783
784 for (_session_id, mut tools) in session_tools {
786 tools.sort_by_key(|t| t.timestamp);
788
789 for window_size in 2..=5.min(tools.len()) {
791 for window in tools.windows(window_size) {
793 let first_time = window[0].timestamp;
795 let last_time = window[window_size - 1].timestamp;
796
797 let time_diff = last_time - first_time;
798 #[allow(clippy::cast_sign_loss)]
799 let time_diff_ms = time_diff
800 .total(jiff::Unit::Millisecond)
801 .unwrap_or(0.0)
802 .abs() as u64;
803
804 if time_diff_ms > MAX_TIME_BETWEEN_TOOLS_MS {
805 continue; }
807
808 let tool_names: Vec<String> =
810 window.iter().map(|t| t.tool_name.clone()).collect();
811
812 let agent = window[0].agent_context.clone();
814
815 let mut time_diffs = Vec::new();
817 for i in 0..window.len() - 1 {
818 let diff = window[i + 1].timestamp - window[i].timestamp;
819 #[allow(clippy::cast_sign_loss)]
820 let diff_ms =
821 diff.total(jiff::Unit::Millisecond).unwrap_or(0.0).abs() as u64;
822 time_diffs.push(diff_ms);
823 }
824
825 let total_with_exit_code =
827 window.iter().filter(|t| t.exit_code.is_some()).count();
828 let successful = window.iter().filter(|t| t.exit_code == Some(0)).count();
829
830 let data = sequence_map
831 .entry(tool_names)
832 .or_insert_with(SequenceData::new);
833 data.frequency += 1;
834 data.time_diffs.extend(time_diffs);
835 data.total_with_exit_code += total_with_exit_code;
836 data.successful += successful;
837
838 if let Some(agent) = agent {
839 *data.agent_counts.entry(agent).or_insert(0) += 1;
840 }
841 }
842 }
843 }
844
845 let mut chains: Vec<ToolChain> = sequence_map
847 .into_iter()
848 .filter(|(_, data)| data.frequency >= 2) .map(|(tools, data)| {
850 #[allow(clippy::cast_precision_loss)]
851 let average_time_between_ms = if data.time_diffs.is_empty() {
852 0
853 } else {
854 data.time_diffs.iter().sum::<u64>() / (data.time_diffs.len() as u64)
855 };
856
857 let typical_agent = data
859 .agent_counts
860 .into_iter()
861 .max_by_key(|(_, count)| *count)
862 .map(|(agent, _)| agent);
863
864 #[allow(clippy::cast_precision_loss)]
865 let success_rate = if data.total_with_exit_code > 0 {
866 (data.successful as f32) / (data.total_with_exit_code as f32)
867 } else {
868 0.0
869 };
870
871 ToolChain {
872 tools,
873 frequency: data.frequency,
874 average_time_between_ms,
875 typical_agent,
876 success_rate,
877 }
878 })
879 .collect();
880
881 chains.sort_by_key(|c| std::cmp::Reverse(c.frequency));
883
884 chains
885 }
886}
887
888#[derive(Debug)]
889struct AgentContribution {
890 operations: Vec<String>,
891 first_interaction: jiff::Timestamp,
892 last_interaction: jiff::Timestamp,
893}
894
895struct AgentToolData {
897 usage_count: u32,
898 success_count: u32,
899 failure_count: u32,
900 session_count: HashSet<String>,
901}
902
903struct ToolStatsData {
905 category: ToolCategory,
906 total_invocations: u32,
907 agents_using: HashSet<String>,
908 success_count: u32,
909 failure_count: u32,
910 first_seen: jiff::Timestamp,
911 last_seen: jiff::Timestamp,
912 command_patterns: HashSet<String>,
913 sessions: HashSet<String>,
914}
915
916#[allow(dead_code)] struct SequenceData {
919 frequency: u32,
920 time_diffs: Vec<u64>,
921 agent_counts: HashMap<String, u32>,
922 total_with_exit_code: usize,
923 successful: usize,
924}
925
926#[allow(dead_code)] impl SequenceData {
928 fn new() -> Self {
929 Self {
930 frequency: 0,
931 time_diffs: Vec::new(),
932 agent_counts: HashMap::new(),
933 total_with_exit_code: 0,
934 successful: 0,
935 }
936 }
937}
938
939#[derive(Debug, Clone)]
940pub struct SummaryStats {
941 pub total_sessions: usize,
942 pub total_agents: usize,
943 pub total_files: usize,
944 pub unique_agent_types: usize,
945 pub most_active_agents: Vec<(String, u32)>,
946}
947
948#[cfg(test)]
949mod tests {
950 use super::*;
951
952 #[test]
953 fn test_calculate_confidence_score() {
954 let _analyzer = Analyzer {
955 parsers: vec![],
956 config: AnalyzerConfig::default(),
957 };
958
959 let operations = vec!["Write".to_string(), "Edit".to_string()];
960 let confidence = Analyzer::calculate_confidence_score(&operations, 75.0);
961
962 assert!(confidence > 0.5);
963 assert!(confidence <= 1.0);
964 }
965
966 #[test]
967 fn test_should_exclude_file() {
968 let config = AnalyzerConfig {
969 exclude_patterns: vec!["node_modules/".to_string(), "target/".to_string()],
970 ..Default::default()
971 };
972
973 let analyzer = Analyzer {
974 parsers: vec![],
975 config,
976 };
977
978 assert!(analyzer.should_exclude_file("node_modules/package.json"));
979 assert!(analyzer.should_exclude_file("target/debug/main"));
980 assert!(!analyzer.should_exclude_file("src/main.rs"));
981 }
982
983 #[test]
984 fn test_calculate_agent_tool_correlations() {
985 use crate::models::{ToolCategory, ToolInvocation};
986 use jiff::Timestamp;
987
988 let analyzer = Analyzer {
989 parsers: vec![],
990 config: AnalyzerConfig::default(),
991 };
992
993 let now = Timestamp::now();
994 let tool_invocations = vec![
995 ToolInvocation {
996 timestamp: now,
997 tool_name: "npm".to_string(),
998 tool_category: ToolCategory::PackageManager,
999 command_line: "npm install".to_string(),
1000 arguments: vec!["install".to_string()],
1001 flags: HashMap::new(),
1002 exit_code: Some(0),
1003 agent_context: Some("developer".to_string()),
1004 session_id: "session-1".to_string(),
1005 message_id: "msg-1".to_string(),
1006 },
1007 ToolInvocation {
1008 timestamp: now,
1009 tool_name: "npm".to_string(),
1010 tool_category: ToolCategory::PackageManager,
1011 command_line: "npm test".to_string(),
1012 arguments: vec!["test".to_string()],
1013 flags: HashMap::new(),
1014 exit_code: Some(0),
1015 agent_context: Some("developer".to_string()),
1016 session_id: "session-1".to_string(),
1017 message_id: "msg-2".to_string(),
1018 },
1019 ToolInvocation {
1020 timestamp: now,
1021 tool_name: "cargo".to_string(),
1022 tool_category: ToolCategory::BuildTool,
1023 command_line: "cargo build".to_string(),
1024 arguments: vec!["build".to_string()],
1025 flags: HashMap::new(),
1026 exit_code: Some(0),
1027 agent_context: Some("rust-expert".to_string()),
1028 session_id: "session-1".to_string(),
1029 message_id: "msg-3".to_string(),
1030 },
1031 ];
1032
1033 let correlations = analyzer.calculate_agent_tool_correlations(&tool_invocations);
1034
1035 assert_eq!(correlations.len(), 2); assert_eq!(correlations[0].usage_count, 2); assert_eq!(correlations[0].agent_type, "developer");
1038 assert_eq!(correlations[0].tool_name, "npm");
1039 assert_eq!(correlations[0].success_rate, 1.0); assert_eq!(correlations[1].usage_count, 1);
1041 assert_eq!(correlations[1].agent_type, "rust-expert");
1042 }
1043
1044 #[test]
1045 fn test_calculate_tool_statistics() {
1046 use crate::models::{ToolCategory, ToolInvocation};
1047 use jiff::Timestamp;
1048
1049 let analyzer = Analyzer {
1050 parsers: vec![],
1051 config: AnalyzerConfig::default(),
1052 };
1053
1054 let now = Timestamp::now();
1055 let tool_invocations = vec![
1056 ToolInvocation {
1057 timestamp: now,
1058 tool_name: "npm".to_string(),
1059 tool_category: ToolCategory::PackageManager,
1060 command_line: "npm install".to_string(),
1061 arguments: vec!["install".to_string()],
1062 flags: HashMap::new(),
1063 exit_code: Some(0),
1064 agent_context: Some("developer".to_string()),
1065 session_id: "session-1".to_string(),
1066 message_id: "msg-1".to_string(),
1067 },
1068 ToolInvocation {
1069 timestamp: now,
1070 tool_name: "npm".to_string(),
1071 tool_category: ToolCategory::PackageManager,
1072 command_line: "npm test".to_string(),
1073 arguments: vec!["test".to_string()],
1074 flags: HashMap::new(),
1075 exit_code: Some(1),
1076 agent_context: Some("developer".to_string()),
1077 session_id: "session-1".to_string(),
1078 message_id: "msg-2".to_string(),
1079 },
1080 ];
1081
1082 let stats = analyzer.calculate_tool_statistics(&tool_invocations);
1083
1084 assert_eq!(stats.len(), 1); let npm_stats = stats.get("npm").unwrap();
1086 assert_eq!(npm_stats.total_invocations, 2);
1087 assert_eq!(npm_stats.success_count, 1);
1088 assert_eq!(npm_stats.failure_count, 1);
1089 assert!(npm_stats.agents_using.contains(&"developer".to_string()));
1090 assert!(matches!(npm_stats.category, ToolCategory::PackageManager));
1091 }
1092
1093 #[test]
1094 fn test_calculate_category_breakdown() {
1095 use crate::models::{ToolCategory, ToolInvocation};
1096 use jiff::Timestamp;
1097
1098 let analyzer = Analyzer {
1099 parsers: vec![],
1100 config: AnalyzerConfig::default(),
1101 };
1102
1103 let now = Timestamp::now();
1104 let tool_invocations = vec![
1105 ToolInvocation {
1106 timestamp: now,
1107 tool_name: "npm".to_string(),
1108 tool_category: ToolCategory::PackageManager,
1109 command_line: "npm install".to_string(),
1110 arguments: vec![],
1111 flags: HashMap::new(),
1112 exit_code: None,
1113 agent_context: Some("developer".to_string()),
1114 session_id: "session-1".to_string(),
1115 message_id: "msg-1".to_string(),
1116 },
1117 ToolInvocation {
1118 timestamp: now,
1119 tool_name: "cargo".to_string(),
1120 tool_category: ToolCategory::BuildTool,
1121 command_line: "cargo build".to_string(),
1122 arguments: vec![],
1123 flags: HashMap::new(),
1124 exit_code: None,
1125 agent_context: Some("rust-expert".to_string()),
1126 session_id: "session-1".to_string(),
1127 message_id: "msg-2".to_string(),
1128 },
1129 ToolInvocation {
1130 timestamp: now,
1131 tool_name: "cargo".to_string(),
1132 tool_category: ToolCategory::BuildTool,
1133 command_line: "cargo test".to_string(),
1134 arguments: vec![],
1135 flags: HashMap::new(),
1136 exit_code: None,
1137 agent_context: Some("rust-expert".to_string()),
1138 session_id: "session-1".to_string(),
1139 message_id: "msg-3".to_string(),
1140 },
1141 ];
1142
1143 let breakdown = analyzer.calculate_category_breakdown(&tool_invocations);
1144
1145 assert_eq!(breakdown.len(), 2);
1146 let categories: Vec<_> = breakdown.keys().collect();
1148 assert!(matches!(categories[0], ToolCategory::BuildTool));
1149 assert_eq!(breakdown[categories[0]], 2);
1150 assert_eq!(breakdown[categories[1]], 1);
1151 }
1152
1153 #[test]
1154 fn test_detect_tool_chains_basic() {
1155 use crate::models::{ToolCategory, ToolInvocation};
1156 use jiff::Timestamp;
1157
1158 let analyzer = Analyzer {
1159 parsers: vec![],
1160 config: AnalyzerConfig::default(),
1161 };
1162
1163 let now = Timestamp::now();
1164 let one_sec = jiff::Span::new().seconds(1);
1165
1166 let tool_invocations = vec![
1168 ToolInvocation {
1170 timestamp: now,
1171 tool_name: "cargo".to_string(),
1172 tool_category: ToolCategory::BuildTool,
1173 command_line: "cargo build".to_string(),
1174 arguments: vec!["build".to_string()],
1175 flags: HashMap::new(),
1176 exit_code: Some(0),
1177 agent_context: Some("developer".to_string()),
1178 session_id: "session-1".to_string(),
1179 message_id: "msg-1".to_string(),
1180 },
1181 ToolInvocation {
1182 timestamp: now.checked_add(one_sec).unwrap(),
1183 tool_name: "cargo".to_string(),
1184 tool_category: ToolCategory::Testing,
1185 command_line: "cargo test".to_string(),
1186 arguments: vec!["test".to_string()],
1187 flags: HashMap::new(),
1188 exit_code: Some(0),
1189 agent_context: Some("developer".to_string()),
1190 session_id: "session-1".to_string(),
1191 message_id: "msg-2".to_string(),
1192 },
1193 ToolInvocation {
1195 timestamp: now.checked_add(jiff::Span::new().seconds(10)).unwrap(),
1196 tool_name: "cargo".to_string(),
1197 tool_category: ToolCategory::BuildTool,
1198 command_line: "cargo build".to_string(),
1199 arguments: vec!["build".to_string()],
1200 flags: HashMap::new(),
1201 exit_code: Some(0),
1202 agent_context: Some("developer".to_string()),
1203 session_id: "session-1".to_string(),
1204 message_id: "msg-3".to_string(),
1205 },
1206 ToolInvocation {
1207 timestamp: now.checked_add(jiff::Span::new().seconds(11)).unwrap(),
1208 tool_name: "cargo".to_string(),
1209 tool_category: ToolCategory::Testing,
1210 command_line: "cargo test".to_string(),
1211 arguments: vec!["test".to_string()],
1212 flags: HashMap::new(),
1213 exit_code: Some(0),
1214 agent_context: Some("developer".to_string()),
1215 session_id: "session-1".to_string(),
1216 message_id: "msg-4".to_string(),
1217 },
1218 ];
1219
1220 let chains = analyzer.detect_tool_chains(&tool_invocations);
1221
1222 assert!(!chains.is_empty(), "Should detect at least one chain");
1223
1224 let cargo_chain = chains
1226 .iter()
1227 .find(|c| c.tools == vec!["cargo".to_string(), "cargo".to_string()]);
1228 assert!(cargo_chain.is_some(), "Should find cargo->cargo chain");
1229
1230 let chain = cargo_chain.unwrap();
1231 assert!(chain.frequency >= 2, "Frequency should be at least 2");
1233 assert_eq!(chain.typical_agent, Some("developer".to_string()));
1234 assert_eq!(chain.success_rate, 1.0);
1235 }
1236
1237 #[test]
1238 fn test_detect_tool_chains_deployment_pipeline() {
1239 use crate::models::{ToolCategory, ToolInvocation};
1240 use jiff::Timestamp;
1241
1242 let analyzer = Analyzer {
1243 parsers: vec![],
1244 config: AnalyzerConfig::default(),
1245 };
1246
1247 let now = Timestamp::now();
1248 let one_sec = jiff::Span::new().seconds(1);
1249
1250 let tool_invocations = vec![
1253 ToolInvocation {
1255 timestamp: now,
1256 tool_name: "npm".to_string(),
1257 tool_category: ToolCategory::PackageManager,
1258 command_line: "npm install".to_string(),
1259 arguments: vec!["install".to_string()],
1260 flags: HashMap::new(),
1261 exit_code: Some(0),
1262 agent_context: Some("devops".to_string()),
1263 session_id: "session-1".to_string(),
1264 message_id: "msg-1".to_string(),
1265 },
1266 ToolInvocation {
1267 timestamp: now.checked_add(one_sec).unwrap(),
1268 tool_name: "npm".to_string(),
1269 tool_category: ToolCategory::BuildTool,
1270 command_line: "npm build".to_string(),
1271 arguments: vec!["build".to_string()],
1272 flags: HashMap::new(),
1273 exit_code: Some(0),
1274 agent_context: Some("devops".to_string()),
1275 session_id: "session-1".to_string(),
1276 message_id: "msg-2".to_string(),
1277 },
1278 ToolInvocation {
1279 timestamp: now.checked_add(jiff::Span::new().seconds(2)).unwrap(),
1280 tool_name: "wrangler".to_string(),
1281 tool_category: ToolCategory::CloudDeploy,
1282 command_line: "wrangler deploy".to_string(),
1283 arguments: vec!["deploy".to_string()],
1284 flags: HashMap::new(),
1285 exit_code: Some(0),
1286 agent_context: Some("devops".to_string()),
1287 session_id: "session-1".to_string(),
1288 message_id: "msg-3".to_string(),
1289 },
1290 ToolInvocation {
1292 timestamp: now.checked_add(jiff::Span::new().minutes(10)).unwrap(),
1293 tool_name: "npm".to_string(),
1294 tool_category: ToolCategory::PackageManager,
1295 command_line: "npm install".to_string(),
1296 arguments: vec!["install".to_string()],
1297 flags: HashMap::new(),
1298 exit_code: Some(0),
1299 agent_context: Some("devops".to_string()),
1300 session_id: "session-2".to_string(),
1301 message_id: "msg-4".to_string(),
1302 },
1303 ToolInvocation {
1304 timestamp: now
1305 .checked_add(jiff::Span::new().minutes(10).seconds(1))
1306 .unwrap(),
1307 tool_name: "npm".to_string(),
1308 tool_category: ToolCategory::BuildTool,
1309 command_line: "npm build".to_string(),
1310 arguments: vec!["build".to_string()],
1311 flags: HashMap::new(),
1312 exit_code: Some(0),
1313 agent_context: Some("devops".to_string()),
1314 session_id: "session-2".to_string(),
1315 message_id: "msg-5".to_string(),
1316 },
1317 ToolInvocation {
1318 timestamp: now
1319 .checked_add(jiff::Span::new().minutes(10).seconds(2))
1320 .unwrap(),
1321 tool_name: "wrangler".to_string(),
1322 tool_category: ToolCategory::CloudDeploy,
1323 command_line: "wrangler deploy".to_string(),
1324 arguments: vec!["deploy".to_string()],
1325 flags: HashMap::new(),
1326 exit_code: Some(0),
1327 agent_context: Some("devops".to_string()),
1328 session_id: "session-2".to_string(),
1329 message_id: "msg-6".to_string(),
1330 },
1331 ];
1332
1333 let chains = analyzer.detect_tool_chains(&tool_invocations);
1334
1335 assert!(!chains.is_empty(), "Should detect deployment chain");
1336
1337 let three_tool_chain = chains.iter().find(|c| c.tools.len() == 3);
1339 assert!(three_tool_chain.is_some(), "Should find 3-tool chain");
1340
1341 let chain = three_tool_chain.unwrap();
1342 assert_eq!(
1343 chain.tools,
1344 vec!["npm".to_string(), "npm".to_string(), "wrangler".to_string()]
1345 );
1346 assert_eq!(chain.frequency, 2);
1347 assert_eq!(chain.typical_agent, Some("devops".to_string()));
1348 assert_eq!(chain.success_rate, 1.0);
1349 }
1350
1351 #[test]
1352 fn test_detect_tool_chains_ignores_single_occurrence() {
1353 use crate::models::{ToolCategory, ToolInvocation};
1354 use jiff::Timestamp;
1355
1356 let analyzer = Analyzer {
1357 parsers: vec![],
1358 config: AnalyzerConfig::default(),
1359 };
1360
1361 let now = Timestamp::now();
1362 let one_sec = jiff::Span::new().seconds(1);
1363
1364 let tool_invocations = vec![
1366 ToolInvocation {
1367 timestamp: now,
1368 tool_name: "npm".to_string(),
1369 tool_category: ToolCategory::PackageManager,
1370 command_line: "npm install".to_string(),
1371 arguments: vec!["install".to_string()],
1372 flags: HashMap::new(),
1373 exit_code: Some(0),
1374 agent_context: Some("developer".to_string()),
1375 session_id: "session-1".to_string(),
1376 message_id: "msg-1".to_string(),
1377 },
1378 ToolInvocation {
1379 timestamp: now.checked_add(one_sec).unwrap(),
1380 tool_name: "npm".to_string(),
1381 tool_category: ToolCategory::Testing,
1382 command_line: "npm test".to_string(),
1383 arguments: vec!["test".to_string()],
1384 flags: HashMap::new(),
1385 exit_code: Some(0),
1386 agent_context: Some("developer".to_string()),
1387 session_id: "session-1".to_string(),
1388 message_id: "msg-2".to_string(),
1389 },
1390 ];
1391
1392 let chains = analyzer.detect_tool_chains(&tool_invocations);
1393
1394 assert!(
1396 chains.is_empty(),
1397 "Should not detect chains that appear only once"
1398 );
1399 }
1400
1401 #[test]
1402 fn test_detect_tool_chains_time_window() {
1403 use crate::models::{ToolCategory, ToolInvocation};
1404 use jiff::Timestamp;
1405
1406 let analyzer = Analyzer {
1407 parsers: vec![],
1408 config: AnalyzerConfig::default(),
1409 };
1410
1411 let now = Timestamp::now();
1412
1413 let tool_invocations = vec![
1415 ToolInvocation {
1416 timestamp: now,
1417 tool_name: "cargo".to_string(),
1418 tool_category: ToolCategory::BuildTool,
1419 command_line: "cargo build".to_string(),
1420 arguments: vec!["build".to_string()],
1421 flags: HashMap::new(),
1422 exit_code: Some(0),
1423 agent_context: Some("developer".to_string()),
1424 session_id: "session-1".to_string(),
1425 message_id: "msg-1".to_string(),
1426 },
1427 ToolInvocation {
1428 timestamp: now.checked_add(jiff::Span::new().hours(2)).unwrap(),
1429 tool_name: "cargo".to_string(),
1430 tool_category: ToolCategory::Testing,
1431 command_line: "cargo test".to_string(),
1432 arguments: vec!["test".to_string()],
1433 flags: HashMap::new(),
1434 exit_code: Some(0),
1435 agent_context: Some("developer".to_string()),
1436 session_id: "session-1".to_string(),
1437 message_id: "msg-2".to_string(),
1438 },
1439 ];
1440
1441 let chains = analyzer.detect_tool_chains(&tool_invocations);
1442
1443 assert!(
1445 chains.is_empty(),
1446 "Should not detect chains with tools too far apart"
1447 );
1448 }
1449
1450 #[test]
1451 fn test_detect_tool_chains_success_rate() {
1452 use crate::models::{ToolCategory, ToolInvocation};
1453 use jiff::Timestamp;
1454
1455 let analyzer = Analyzer {
1456 parsers: vec![],
1457 config: AnalyzerConfig::default(),
1458 };
1459
1460 let now = Timestamp::now();
1461 let one_sec = jiff::Span::new().seconds(1);
1462
1463 let tool_invocations = vec![
1465 ToolInvocation {
1467 timestamp: now,
1468 tool_name: "cargo".to_string(),
1469 tool_category: ToolCategory::BuildTool,
1470 command_line: "cargo build".to_string(),
1471 arguments: vec!["build".to_string()],
1472 flags: HashMap::new(),
1473 exit_code: Some(0),
1474 agent_context: Some("developer".to_string()),
1475 session_id: "session-1".to_string(),
1476 message_id: "msg-1".to_string(),
1477 },
1478 ToolInvocation {
1479 timestamp: now.checked_add(one_sec).unwrap(),
1480 tool_name: "cargo".to_string(),
1481 tool_category: ToolCategory::Testing,
1482 command_line: "cargo test".to_string(),
1483 arguments: vec!["test".to_string()],
1484 flags: HashMap::new(),
1485 exit_code: Some(0),
1486 agent_context: Some("developer".to_string()),
1487 session_id: "session-1".to_string(),
1488 message_id: "msg-2".to_string(),
1489 },
1490 ToolInvocation {
1492 timestamp: now.checked_add(jiff::Span::new().seconds(10)).unwrap(),
1493 tool_name: "cargo".to_string(),
1494 tool_category: ToolCategory::BuildTool,
1495 command_line: "cargo build".to_string(),
1496 arguments: vec!["build".to_string()],
1497 flags: HashMap::new(),
1498 exit_code: Some(0),
1499 agent_context: Some("developer".to_string()),
1500 session_id: "session-1".to_string(),
1501 message_id: "msg-3".to_string(),
1502 },
1503 ToolInvocation {
1504 timestamp: now.checked_add(jiff::Span::new().seconds(11)).unwrap(),
1505 tool_name: "cargo".to_string(),
1506 tool_category: ToolCategory::Testing,
1507 command_line: "cargo test".to_string(),
1508 arguments: vec!["test".to_string()],
1509 flags: HashMap::new(),
1510 exit_code: Some(1),
1511 agent_context: Some("developer".to_string()),
1512 session_id: "session-1".to_string(),
1513 message_id: "msg-4".to_string(),
1514 },
1515 ];
1516
1517 let chains = analyzer.detect_tool_chains(&tool_invocations);
1518
1519 assert!(!chains.is_empty(), "Should detect chain");
1520
1521 let cargo_chain = chains
1523 .iter()
1524 .find(|c| c.tools == vec!["cargo".to_string(), "cargo".to_string()]);
1525 assert!(cargo_chain.is_some(), "Should find cargo->cargo chain");
1526
1527 let chain = cargo_chain.unwrap();
1528 assert!(chain.frequency >= 2, "Frequency should be at least 2");
1529 assert!(
1535 chain.success_rate >= 0.82 && chain.success_rate <= 0.84,
1536 "Success rate should be around 0.83, got {}",
1537 chain.success_rate
1538 );
1539 }
1540}