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(|a, b| a.timestamp.cmp(&b.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(|a, b| b.1.cmp(&a.1));
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(|a, b| b.usage_count.cmp(&a.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 stats.sort_by(|_, v1, _, v2| v2.total_invocations.cmp(&v1.total_invocations));
719
720 stats
721 }
722
723 #[must_use]
725 pub fn calculate_category_breakdown(
726 &self,
727 tool_invocations: &[ToolInvocation],
728 ) -> IndexMap<ToolCategory, u32> {
729 let mut category_counts: HashMap<ToolCategory, u32> = HashMap::new();
730
731 for invocation in tool_invocations {
732 *category_counts
733 .entry(invocation.tool_category.clone())
734 .or_insert(0) += 1;
735 }
736
737 let mut breakdown: IndexMap<ToolCategory, u32> = category_counts.into_iter().collect();
739 breakdown.sort_by(|_, v1, _, v2| v2.cmp(v1));
740
741 breakdown
742 }
743
744 #[must_use]
760 #[allow(dead_code)] pub fn detect_tool_chains(
762 &self,
763 tool_invocations: &[ToolInvocation],
764 ) -> Vec<crate::models::ToolChain> {
765 use crate::models::ToolChain;
766
767 let mut session_tools: HashMap<String, Vec<&ToolInvocation>> = HashMap::new();
769 for invocation in tool_invocations {
770 session_tools
771 .entry(invocation.session_id.clone())
772 .or_default()
773 .push(invocation);
774 }
775
776 let mut sequence_map: HashMap<Vec<String>, SequenceData> = HashMap::new();
778
779 const MAX_TIME_BETWEEN_TOOLS_MS: u64 = 3_600_000;
781
782 for (_session_id, mut tools) in session_tools {
784 tools.sort_by_key(|t| t.timestamp);
786
787 for window_size in 2..=5.min(tools.len()) {
789 for window in tools.windows(window_size) {
791 let first_time = window[0].timestamp;
793 let last_time = window[window_size - 1].timestamp;
794
795 let time_diff = last_time - first_time;
796 #[allow(clippy::cast_sign_loss)]
797 let time_diff_ms = time_diff
798 .total(jiff::Unit::Millisecond)
799 .unwrap_or(0.0)
800 .abs() as u64;
801
802 if time_diff_ms > MAX_TIME_BETWEEN_TOOLS_MS {
803 continue; }
805
806 let tool_names: Vec<String> =
808 window.iter().map(|t| t.tool_name.clone()).collect();
809
810 let agent = window[0].agent_context.clone();
812
813 let mut time_diffs = Vec::new();
815 for i in 0..window.len() - 1 {
816 let diff = window[i + 1].timestamp - window[i].timestamp;
817 #[allow(clippy::cast_sign_loss)]
818 let diff_ms =
819 diff.total(jiff::Unit::Millisecond).unwrap_or(0.0).abs() as u64;
820 time_diffs.push(diff_ms);
821 }
822
823 let total_with_exit_code =
825 window.iter().filter(|t| t.exit_code.is_some()).count();
826 let successful = window.iter().filter(|t| t.exit_code == Some(0)).count();
827
828 let data = sequence_map
829 .entry(tool_names)
830 .or_insert_with(SequenceData::new);
831 data.frequency += 1;
832 data.time_diffs.extend(time_diffs);
833 data.total_with_exit_code += total_with_exit_code;
834 data.successful += successful;
835
836 if let Some(agent) = agent {
837 *data.agent_counts.entry(agent).or_insert(0) += 1;
838 }
839 }
840 }
841 }
842
843 let mut chains: Vec<ToolChain> = sequence_map
845 .into_iter()
846 .filter(|(_, data)| data.frequency >= 2) .map(|(tools, data)| {
848 #[allow(clippy::cast_precision_loss)]
849 let average_time_between_ms = if data.time_diffs.is_empty() {
850 0
851 } else {
852 data.time_diffs.iter().sum::<u64>() / (data.time_diffs.len() as u64)
853 };
854
855 let typical_agent = data
857 .agent_counts
858 .into_iter()
859 .max_by_key(|(_, count)| *count)
860 .map(|(agent, _)| agent);
861
862 #[allow(clippy::cast_precision_loss)]
863 let success_rate = if data.total_with_exit_code > 0 {
864 (data.successful as f32) / (data.total_with_exit_code as f32)
865 } else {
866 0.0
867 };
868
869 ToolChain {
870 tools,
871 frequency: data.frequency,
872 average_time_between_ms,
873 typical_agent,
874 success_rate,
875 }
876 })
877 .collect();
878
879 chains.sort_by(|a, b| b.frequency.cmp(&a.frequency));
881
882 chains
883 }
884}
885
886#[derive(Debug)]
887struct AgentContribution {
888 operations: Vec<String>,
889 first_interaction: jiff::Timestamp,
890 last_interaction: jiff::Timestamp,
891}
892
893struct AgentToolData {
895 usage_count: u32,
896 success_count: u32,
897 failure_count: u32,
898 session_count: HashSet<String>,
899}
900
901struct ToolStatsData {
903 category: ToolCategory,
904 total_invocations: u32,
905 agents_using: HashSet<String>,
906 success_count: u32,
907 failure_count: u32,
908 first_seen: jiff::Timestamp,
909 last_seen: jiff::Timestamp,
910 command_patterns: HashSet<String>,
911 sessions: HashSet<String>,
912}
913
914#[allow(dead_code)] struct SequenceData {
917 frequency: u32,
918 time_diffs: Vec<u64>,
919 agent_counts: HashMap<String, u32>,
920 total_with_exit_code: usize,
921 successful: usize,
922}
923
924#[allow(dead_code)] impl SequenceData {
926 fn new() -> Self {
927 Self {
928 frequency: 0,
929 time_diffs: Vec::new(),
930 agent_counts: HashMap::new(),
931 total_with_exit_code: 0,
932 successful: 0,
933 }
934 }
935}
936
937#[derive(Debug, Clone)]
938pub struct SummaryStats {
939 pub total_sessions: usize,
940 pub total_agents: usize,
941 pub total_files: usize,
942 pub unique_agent_types: usize,
943 pub most_active_agents: Vec<(String, u32)>,
944}
945
946#[cfg(test)]
947mod tests {
948 use super::*;
949
950 #[test]
951 fn test_calculate_confidence_score() {
952 let _analyzer = Analyzer {
953 parsers: vec![],
954 config: AnalyzerConfig::default(),
955 };
956
957 let operations = vec!["Write".to_string(), "Edit".to_string()];
958 let confidence = Analyzer::calculate_confidence_score(&operations, 75.0);
959
960 assert!(confidence > 0.5);
961 assert!(confidence <= 1.0);
962 }
963
964 #[test]
965 fn test_should_exclude_file() {
966 let config = AnalyzerConfig {
967 exclude_patterns: vec!["node_modules/".to_string(), "target/".to_string()],
968 ..Default::default()
969 };
970
971 let analyzer = Analyzer {
972 parsers: vec![],
973 config,
974 };
975
976 assert!(analyzer.should_exclude_file("node_modules/package.json"));
977 assert!(analyzer.should_exclude_file("target/debug/main"));
978 assert!(!analyzer.should_exclude_file("src/main.rs"));
979 }
980
981 #[test]
982 fn test_calculate_agent_tool_correlations() {
983 use crate::models::{ToolCategory, ToolInvocation};
984 use jiff::Timestamp;
985
986 let analyzer = Analyzer {
987 parsers: vec![],
988 config: AnalyzerConfig::default(),
989 };
990
991 let now = Timestamp::now();
992 let tool_invocations = vec![
993 ToolInvocation {
994 timestamp: now,
995 tool_name: "npm".to_string(),
996 tool_category: ToolCategory::PackageManager,
997 command_line: "npm install".to_string(),
998 arguments: vec!["install".to_string()],
999 flags: HashMap::new(),
1000 exit_code: Some(0),
1001 agent_context: Some("developer".to_string()),
1002 session_id: "session-1".to_string(),
1003 message_id: "msg-1".to_string(),
1004 },
1005 ToolInvocation {
1006 timestamp: now,
1007 tool_name: "npm".to_string(),
1008 tool_category: ToolCategory::PackageManager,
1009 command_line: "npm test".to_string(),
1010 arguments: vec!["test".to_string()],
1011 flags: HashMap::new(),
1012 exit_code: Some(0),
1013 agent_context: Some("developer".to_string()),
1014 session_id: "session-1".to_string(),
1015 message_id: "msg-2".to_string(),
1016 },
1017 ToolInvocation {
1018 timestamp: now,
1019 tool_name: "cargo".to_string(),
1020 tool_category: ToolCategory::BuildTool,
1021 command_line: "cargo build".to_string(),
1022 arguments: vec!["build".to_string()],
1023 flags: HashMap::new(),
1024 exit_code: Some(0),
1025 agent_context: Some("rust-expert".to_string()),
1026 session_id: "session-1".to_string(),
1027 message_id: "msg-3".to_string(),
1028 },
1029 ];
1030
1031 let correlations = analyzer.calculate_agent_tool_correlations(&tool_invocations);
1032
1033 assert_eq!(correlations.len(), 2); assert_eq!(correlations[0].usage_count, 2); assert_eq!(correlations[0].agent_type, "developer");
1036 assert_eq!(correlations[0].tool_name, "npm");
1037 assert_eq!(correlations[0].success_rate, 1.0); assert_eq!(correlations[1].usage_count, 1);
1039 assert_eq!(correlations[1].agent_type, "rust-expert");
1040 }
1041
1042 #[test]
1043 fn test_calculate_tool_statistics() {
1044 use crate::models::{ToolCategory, ToolInvocation};
1045 use jiff::Timestamp;
1046
1047 let analyzer = Analyzer {
1048 parsers: vec![],
1049 config: AnalyzerConfig::default(),
1050 };
1051
1052 let now = Timestamp::now();
1053 let tool_invocations = vec![
1054 ToolInvocation {
1055 timestamp: now,
1056 tool_name: "npm".to_string(),
1057 tool_category: ToolCategory::PackageManager,
1058 command_line: "npm install".to_string(),
1059 arguments: vec!["install".to_string()],
1060 flags: HashMap::new(),
1061 exit_code: Some(0),
1062 agent_context: Some("developer".to_string()),
1063 session_id: "session-1".to_string(),
1064 message_id: "msg-1".to_string(),
1065 },
1066 ToolInvocation {
1067 timestamp: now,
1068 tool_name: "npm".to_string(),
1069 tool_category: ToolCategory::PackageManager,
1070 command_line: "npm test".to_string(),
1071 arguments: vec!["test".to_string()],
1072 flags: HashMap::new(),
1073 exit_code: Some(1),
1074 agent_context: Some("developer".to_string()),
1075 session_id: "session-1".to_string(),
1076 message_id: "msg-2".to_string(),
1077 },
1078 ];
1079
1080 let stats = analyzer.calculate_tool_statistics(&tool_invocations);
1081
1082 assert_eq!(stats.len(), 1); let npm_stats = stats.get("npm").unwrap();
1084 assert_eq!(npm_stats.total_invocations, 2);
1085 assert_eq!(npm_stats.success_count, 1);
1086 assert_eq!(npm_stats.failure_count, 1);
1087 assert!(npm_stats.agents_using.contains(&"developer".to_string()));
1088 assert!(matches!(npm_stats.category, ToolCategory::PackageManager));
1089 }
1090
1091 #[test]
1092 fn test_calculate_category_breakdown() {
1093 use crate::models::{ToolCategory, ToolInvocation};
1094 use jiff::Timestamp;
1095
1096 let analyzer = Analyzer {
1097 parsers: vec![],
1098 config: AnalyzerConfig::default(),
1099 };
1100
1101 let now = Timestamp::now();
1102 let tool_invocations = vec![
1103 ToolInvocation {
1104 timestamp: now,
1105 tool_name: "npm".to_string(),
1106 tool_category: ToolCategory::PackageManager,
1107 command_line: "npm install".to_string(),
1108 arguments: vec![],
1109 flags: HashMap::new(),
1110 exit_code: None,
1111 agent_context: Some("developer".to_string()),
1112 session_id: "session-1".to_string(),
1113 message_id: "msg-1".to_string(),
1114 },
1115 ToolInvocation {
1116 timestamp: now,
1117 tool_name: "cargo".to_string(),
1118 tool_category: ToolCategory::BuildTool,
1119 command_line: "cargo build".to_string(),
1120 arguments: vec![],
1121 flags: HashMap::new(),
1122 exit_code: None,
1123 agent_context: Some("rust-expert".to_string()),
1124 session_id: "session-1".to_string(),
1125 message_id: "msg-2".to_string(),
1126 },
1127 ToolInvocation {
1128 timestamp: now,
1129 tool_name: "cargo".to_string(),
1130 tool_category: ToolCategory::BuildTool,
1131 command_line: "cargo test".to_string(),
1132 arguments: vec![],
1133 flags: HashMap::new(),
1134 exit_code: None,
1135 agent_context: Some("rust-expert".to_string()),
1136 session_id: "session-1".to_string(),
1137 message_id: "msg-3".to_string(),
1138 },
1139 ];
1140
1141 let breakdown = analyzer.calculate_category_breakdown(&tool_invocations);
1142
1143 assert_eq!(breakdown.len(), 2);
1144 let categories: Vec<_> = breakdown.keys().collect();
1146 assert!(matches!(categories[0], ToolCategory::BuildTool));
1147 assert_eq!(breakdown[categories[0]], 2);
1148 assert_eq!(breakdown[categories[1]], 1);
1149 }
1150
1151 #[test]
1152 fn test_detect_tool_chains_basic() {
1153 use crate::models::{ToolCategory, ToolInvocation};
1154 use jiff::Timestamp;
1155
1156 let analyzer = Analyzer {
1157 parsers: vec![],
1158 config: AnalyzerConfig::default(),
1159 };
1160
1161 let now = Timestamp::now();
1162 let one_sec = jiff::Span::new().seconds(1);
1163
1164 let tool_invocations = vec![
1166 ToolInvocation {
1168 timestamp: now,
1169 tool_name: "cargo".to_string(),
1170 tool_category: ToolCategory::BuildTool,
1171 command_line: "cargo build".to_string(),
1172 arguments: vec!["build".to_string()],
1173 flags: HashMap::new(),
1174 exit_code: Some(0),
1175 agent_context: Some("developer".to_string()),
1176 session_id: "session-1".to_string(),
1177 message_id: "msg-1".to_string(),
1178 },
1179 ToolInvocation {
1180 timestamp: now.checked_add(one_sec).unwrap(),
1181 tool_name: "cargo".to_string(),
1182 tool_category: ToolCategory::Testing,
1183 command_line: "cargo test".to_string(),
1184 arguments: vec!["test".to_string()],
1185 flags: HashMap::new(),
1186 exit_code: Some(0),
1187 agent_context: Some("developer".to_string()),
1188 session_id: "session-1".to_string(),
1189 message_id: "msg-2".to_string(),
1190 },
1191 ToolInvocation {
1193 timestamp: now.checked_add(jiff::Span::new().seconds(10)).unwrap(),
1194 tool_name: "cargo".to_string(),
1195 tool_category: ToolCategory::BuildTool,
1196 command_line: "cargo build".to_string(),
1197 arguments: vec!["build".to_string()],
1198 flags: HashMap::new(),
1199 exit_code: Some(0),
1200 agent_context: Some("developer".to_string()),
1201 session_id: "session-1".to_string(),
1202 message_id: "msg-3".to_string(),
1203 },
1204 ToolInvocation {
1205 timestamp: now.checked_add(jiff::Span::new().seconds(11)).unwrap(),
1206 tool_name: "cargo".to_string(),
1207 tool_category: ToolCategory::Testing,
1208 command_line: "cargo test".to_string(),
1209 arguments: vec!["test".to_string()],
1210 flags: HashMap::new(),
1211 exit_code: Some(0),
1212 agent_context: Some("developer".to_string()),
1213 session_id: "session-1".to_string(),
1214 message_id: "msg-4".to_string(),
1215 },
1216 ];
1217
1218 let chains = analyzer.detect_tool_chains(&tool_invocations);
1219
1220 assert!(!chains.is_empty(), "Should detect at least one chain");
1221
1222 let cargo_chain = chains
1224 .iter()
1225 .find(|c| c.tools == vec!["cargo".to_string(), "cargo".to_string()]);
1226 assert!(cargo_chain.is_some(), "Should find cargo->cargo chain");
1227
1228 let chain = cargo_chain.unwrap();
1229 assert!(chain.frequency >= 2, "Frequency should be at least 2");
1231 assert_eq!(chain.typical_agent, Some("developer".to_string()));
1232 assert_eq!(chain.success_rate, 1.0);
1233 }
1234
1235 #[test]
1236 fn test_detect_tool_chains_deployment_pipeline() {
1237 use crate::models::{ToolCategory, ToolInvocation};
1238 use jiff::Timestamp;
1239
1240 let analyzer = Analyzer {
1241 parsers: vec![],
1242 config: AnalyzerConfig::default(),
1243 };
1244
1245 let now = Timestamp::now();
1246 let one_sec = jiff::Span::new().seconds(1);
1247
1248 let tool_invocations = vec![
1251 ToolInvocation {
1253 timestamp: now,
1254 tool_name: "npm".to_string(),
1255 tool_category: ToolCategory::PackageManager,
1256 command_line: "npm install".to_string(),
1257 arguments: vec!["install".to_string()],
1258 flags: HashMap::new(),
1259 exit_code: Some(0),
1260 agent_context: Some("devops".to_string()),
1261 session_id: "session-1".to_string(),
1262 message_id: "msg-1".to_string(),
1263 },
1264 ToolInvocation {
1265 timestamp: now.checked_add(one_sec).unwrap(),
1266 tool_name: "npm".to_string(),
1267 tool_category: ToolCategory::BuildTool,
1268 command_line: "npm build".to_string(),
1269 arguments: vec!["build".to_string()],
1270 flags: HashMap::new(),
1271 exit_code: Some(0),
1272 agent_context: Some("devops".to_string()),
1273 session_id: "session-1".to_string(),
1274 message_id: "msg-2".to_string(),
1275 },
1276 ToolInvocation {
1277 timestamp: now.checked_add(jiff::Span::new().seconds(2)).unwrap(),
1278 tool_name: "wrangler".to_string(),
1279 tool_category: ToolCategory::CloudDeploy,
1280 command_line: "wrangler deploy".to_string(),
1281 arguments: vec!["deploy".to_string()],
1282 flags: HashMap::new(),
1283 exit_code: Some(0),
1284 agent_context: Some("devops".to_string()),
1285 session_id: "session-1".to_string(),
1286 message_id: "msg-3".to_string(),
1287 },
1288 ToolInvocation {
1290 timestamp: now.checked_add(jiff::Span::new().minutes(10)).unwrap(),
1291 tool_name: "npm".to_string(),
1292 tool_category: ToolCategory::PackageManager,
1293 command_line: "npm install".to_string(),
1294 arguments: vec!["install".to_string()],
1295 flags: HashMap::new(),
1296 exit_code: Some(0),
1297 agent_context: Some("devops".to_string()),
1298 session_id: "session-2".to_string(),
1299 message_id: "msg-4".to_string(),
1300 },
1301 ToolInvocation {
1302 timestamp: now
1303 .checked_add(jiff::Span::new().minutes(10).seconds(1))
1304 .unwrap(),
1305 tool_name: "npm".to_string(),
1306 tool_category: ToolCategory::BuildTool,
1307 command_line: "npm build".to_string(),
1308 arguments: vec!["build".to_string()],
1309 flags: HashMap::new(),
1310 exit_code: Some(0),
1311 agent_context: Some("devops".to_string()),
1312 session_id: "session-2".to_string(),
1313 message_id: "msg-5".to_string(),
1314 },
1315 ToolInvocation {
1316 timestamp: now
1317 .checked_add(jiff::Span::new().minutes(10).seconds(2))
1318 .unwrap(),
1319 tool_name: "wrangler".to_string(),
1320 tool_category: ToolCategory::CloudDeploy,
1321 command_line: "wrangler deploy".to_string(),
1322 arguments: vec!["deploy".to_string()],
1323 flags: HashMap::new(),
1324 exit_code: Some(0),
1325 agent_context: Some("devops".to_string()),
1326 session_id: "session-2".to_string(),
1327 message_id: "msg-6".to_string(),
1328 },
1329 ];
1330
1331 let chains = analyzer.detect_tool_chains(&tool_invocations);
1332
1333 assert!(!chains.is_empty(), "Should detect deployment chain");
1334
1335 let three_tool_chain = chains.iter().find(|c| c.tools.len() == 3);
1337 assert!(three_tool_chain.is_some(), "Should find 3-tool chain");
1338
1339 let chain = three_tool_chain.unwrap();
1340 assert_eq!(
1341 chain.tools,
1342 vec!["npm".to_string(), "npm".to_string(), "wrangler".to_string()]
1343 );
1344 assert_eq!(chain.frequency, 2);
1345 assert_eq!(chain.typical_agent, Some("devops".to_string()));
1346 assert_eq!(chain.success_rate, 1.0);
1347 }
1348
1349 #[test]
1350 fn test_detect_tool_chains_ignores_single_occurrence() {
1351 use crate::models::{ToolCategory, ToolInvocation};
1352 use jiff::Timestamp;
1353
1354 let analyzer = Analyzer {
1355 parsers: vec![],
1356 config: AnalyzerConfig::default(),
1357 };
1358
1359 let now = Timestamp::now();
1360 let one_sec = jiff::Span::new().seconds(1);
1361
1362 let tool_invocations = vec![
1364 ToolInvocation {
1365 timestamp: now,
1366 tool_name: "npm".to_string(),
1367 tool_category: ToolCategory::PackageManager,
1368 command_line: "npm install".to_string(),
1369 arguments: vec!["install".to_string()],
1370 flags: HashMap::new(),
1371 exit_code: Some(0),
1372 agent_context: Some("developer".to_string()),
1373 session_id: "session-1".to_string(),
1374 message_id: "msg-1".to_string(),
1375 },
1376 ToolInvocation {
1377 timestamp: now.checked_add(one_sec).unwrap(),
1378 tool_name: "npm".to_string(),
1379 tool_category: ToolCategory::Testing,
1380 command_line: "npm test".to_string(),
1381 arguments: vec!["test".to_string()],
1382 flags: HashMap::new(),
1383 exit_code: Some(0),
1384 agent_context: Some("developer".to_string()),
1385 session_id: "session-1".to_string(),
1386 message_id: "msg-2".to_string(),
1387 },
1388 ];
1389
1390 let chains = analyzer.detect_tool_chains(&tool_invocations);
1391
1392 assert!(
1394 chains.is_empty(),
1395 "Should not detect chains that appear only once"
1396 );
1397 }
1398
1399 #[test]
1400 fn test_detect_tool_chains_time_window() {
1401 use crate::models::{ToolCategory, ToolInvocation};
1402 use jiff::Timestamp;
1403
1404 let analyzer = Analyzer {
1405 parsers: vec![],
1406 config: AnalyzerConfig::default(),
1407 };
1408
1409 let now = Timestamp::now();
1410
1411 let tool_invocations = vec![
1413 ToolInvocation {
1414 timestamp: now,
1415 tool_name: "cargo".to_string(),
1416 tool_category: ToolCategory::BuildTool,
1417 command_line: "cargo build".to_string(),
1418 arguments: vec!["build".to_string()],
1419 flags: HashMap::new(),
1420 exit_code: Some(0),
1421 agent_context: Some("developer".to_string()),
1422 session_id: "session-1".to_string(),
1423 message_id: "msg-1".to_string(),
1424 },
1425 ToolInvocation {
1426 timestamp: now.checked_add(jiff::Span::new().hours(2)).unwrap(),
1427 tool_name: "cargo".to_string(),
1428 tool_category: ToolCategory::Testing,
1429 command_line: "cargo test".to_string(),
1430 arguments: vec!["test".to_string()],
1431 flags: HashMap::new(),
1432 exit_code: Some(0),
1433 agent_context: Some("developer".to_string()),
1434 session_id: "session-1".to_string(),
1435 message_id: "msg-2".to_string(),
1436 },
1437 ];
1438
1439 let chains = analyzer.detect_tool_chains(&tool_invocations);
1440
1441 assert!(
1443 chains.is_empty(),
1444 "Should not detect chains with tools too far apart"
1445 );
1446 }
1447
1448 #[test]
1449 fn test_detect_tool_chains_success_rate() {
1450 use crate::models::{ToolCategory, ToolInvocation};
1451 use jiff::Timestamp;
1452
1453 let analyzer = Analyzer {
1454 parsers: vec![],
1455 config: AnalyzerConfig::default(),
1456 };
1457
1458 let now = Timestamp::now();
1459 let one_sec = jiff::Span::new().seconds(1);
1460
1461 let tool_invocations = vec![
1463 ToolInvocation {
1465 timestamp: now,
1466 tool_name: "cargo".to_string(),
1467 tool_category: ToolCategory::BuildTool,
1468 command_line: "cargo build".to_string(),
1469 arguments: vec!["build".to_string()],
1470 flags: HashMap::new(),
1471 exit_code: Some(0),
1472 agent_context: Some("developer".to_string()),
1473 session_id: "session-1".to_string(),
1474 message_id: "msg-1".to_string(),
1475 },
1476 ToolInvocation {
1477 timestamp: now.checked_add(one_sec).unwrap(),
1478 tool_name: "cargo".to_string(),
1479 tool_category: ToolCategory::Testing,
1480 command_line: "cargo test".to_string(),
1481 arguments: vec!["test".to_string()],
1482 flags: HashMap::new(),
1483 exit_code: Some(0),
1484 agent_context: Some("developer".to_string()),
1485 session_id: "session-1".to_string(),
1486 message_id: "msg-2".to_string(),
1487 },
1488 ToolInvocation {
1490 timestamp: now.checked_add(jiff::Span::new().seconds(10)).unwrap(),
1491 tool_name: "cargo".to_string(),
1492 tool_category: ToolCategory::BuildTool,
1493 command_line: "cargo build".to_string(),
1494 arguments: vec!["build".to_string()],
1495 flags: HashMap::new(),
1496 exit_code: Some(0),
1497 agent_context: Some("developer".to_string()),
1498 session_id: "session-1".to_string(),
1499 message_id: "msg-3".to_string(),
1500 },
1501 ToolInvocation {
1502 timestamp: now.checked_add(jiff::Span::new().seconds(11)).unwrap(),
1503 tool_name: "cargo".to_string(),
1504 tool_category: ToolCategory::Testing,
1505 command_line: "cargo test".to_string(),
1506 arguments: vec!["test".to_string()],
1507 flags: HashMap::new(),
1508 exit_code: Some(1),
1509 agent_context: Some("developer".to_string()),
1510 session_id: "session-1".to_string(),
1511 message_id: "msg-4".to_string(),
1512 },
1513 ];
1514
1515 let chains = analyzer.detect_tool_chains(&tool_invocations);
1516
1517 assert!(!chains.is_empty(), "Should detect chain");
1518
1519 let cargo_chain = chains
1521 .iter()
1522 .find(|c| c.tools == vec!["cargo".to_string(), "cargo".to_string()]);
1523 assert!(cargo_chain.is_some(), "Should find cargo->cargo chain");
1524
1525 let chain = cargo_chain.unwrap();
1526 assert!(chain.frequency >= 2, "Frequency should be at least 2");
1527 assert!(
1533 chain.success_rate >= 0.82 && chain.success_rate <= 0.84,
1534 "Success rate should be around 0.83, got {}",
1535 chain.success_rate
1536 );
1537 }
1538}