Skip to main content

terraphim_session_analyzer/
analyzer.rs

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    /// Create analyzer from a specific path (file or directory)
21    ///
22    /// # Errors
23    ///
24    /// Returns an error if the path doesn't exist or cannot be read
25    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    /// Create analyzer from default Claude session location
42    ///
43    /// # Errors
44    ///
45    /// Returns an error if the default Claude directory doesn't exist
46    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    /// Set custom configuration
55    /// Used in integration tests
56    #[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    /// Analyze all sessions or filter by target file
64    ///
65    /// # Errors
66    ///
67    /// Returns an error if session parsing or analysis fails
68    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 target file specified, only include sessions with relevant operations
78                        if let Some(_target) = target_file {
79                            if analysis.file_operations.is_empty() {
80                                return None; // Skip sessions without target file operations
81                            }
82                        }
83                        Some(Ok(analysis))
84                    }
85                    Err(e) => Some(Err(e)),
86                }
87            })
88            .collect();
89
90        analyses
91    }
92
93    /// Analyze a single session
94    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        // Extract raw data
104        let mut agents = parser.extract_agent_invocations();
105        let mut file_operations = parser.extract_file_operations();
106
107        // Sort agents by timestamp for efficient binary search in set_agent_context
108        agents.sort_by_key(|a| a.timestamp);
109
110        // Set agent context for file operations
111        self.set_agent_context(&mut file_operations, &agents, parser);
112
113        // Calculate agent durations
114        Self::calculate_agent_durations(&mut agents);
115
116        // Filter by target file if specified
117        if let Some(target) = target_file {
118            file_operations.retain(|op| op.file_path.contains(target));
119
120            // Also filter agents to only those that worked on the target file
121            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        // Build file-to-agent attributions
130        let file_to_agents = self.build_file_attributions(&file_operations, &agents);
131
132        // Calculate agent statistics
133        let agent_stats = Self::calculate_agent_statistics(&agents, &file_operations);
134
135        // Detect collaboration patterns
136        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    /// Set agent context for file operations based on temporal proximity
162    /// Uses binary search for O(log n) lookup instead of O(n) linear search
163    fn set_agent_context(
164        &self,
165        file_operations: &mut [FileOperation],
166        agents: &[AgentInvocation],
167        parser: &SessionParser,
168    ) {
169        // Ensure agents are sorted by timestamp for binary search
170        // (they should already be sorted from extraction, but let's verify)
171        debug_assert!(agents.windows(2).all(|w| w[0].timestamp <= w[1].timestamp));
172
173        for file_op in file_operations.iter_mut() {
174            // First try to find agent from parser's context lookup
175            if let Some(agent) = parser.find_active_agent(&file_op.message_id) {
176                file_op.agent_context = Some(agent);
177                continue;
178            }
179
180            // Use binary search to find the most recent agent before this file operation
181            // We're looking for the rightmost agent with timestamp <= file_op.timestamp
182            let agent_idx = match agents.binary_search_by_key(&file_op.timestamp, |a| a.timestamp) {
183                Ok(idx) => Some(idx), // Exact match
184                Err(idx) => {
185                    if idx > 0 {
186                        Some(idx - 1) // Previous agent is the most recent before this operation
187                    } else {
188                        None // No agent before this operation
189                    }
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    /// Calculate durations for agent invocations
208    fn calculate_agent_durations(agents: &mut [AgentInvocation]) {
209        // Sort by timestamp for duration calculation
210        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    /// Build file-to-agent attribution mapping
225    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        // Group file operations by file path
233        let mut file_groups: HashMap<String, Vec<&FileOperation>> = HashMap::new();
234        for op in file_operations {
235            // Skip files matching exclude patterns
236            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        // Calculate attributions for each file
247        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            // Convert to attributions with percentages
271            #[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    /// Calculate agent statistics
308    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        // Group agents by type
315        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        // Calculate statistics for each agent type
324        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    /// Detect collaboration patterns between agents
374    fn detect_collaboration_patterns(
375        &self,
376        agents: &[AgentInvocation],
377    ) -> Vec<CollaborationPattern> {
378        let mut patterns = Vec::new();
379
380        // Sequential pattern: architect -> developer -> test-writer
381        let sequential_pattern = Self::detect_sequential_pattern(agents);
382        if let Some(pattern) = sequential_pattern {
383            patterns.push(pattern);
384        }
385
386        // Parallel pattern: multiple agents working simultaneously
387        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    /// Detect sequential collaboration patterns
396    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    /// Detect parallel collaboration patterns
419    fn detect_parallel_pattern(agents: &[AgentInvocation]) -> Option<CollaborationPattern> {
420        // Group agents by time windows
421        let window_ms = 300_000; // 5 minutes
422        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        // Find groups with multiple different agents
445        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    /// Check if agents match a specific sequence
471    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        // Simple substring matching for now
475        for window in agent_types.windows(sequence.len()) {
476            if window == sequence {
477                return true;
478            }
479        }
480
481        false
482    }
483
484    /// Calculate confidence score for agent attribution
485    fn calculate_confidence_score(operations: &[String], contribution_percent: f32) -> f32 {
486        let mut confidence = 0.5; // Base confidence
487
488        // Higher confidence for more operations
489        #[allow(clippy::cast_precision_loss)]
490        {
491            confidence += (operations.len() as f32 * 0.1).min(0.3);
492        }
493
494        // Higher confidence for higher contribution percentage
495        confidence += (contribution_percent / 100.0) * 0.4;
496
497        // Boost confidence for write operations vs read operations
498        #[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    /// Check if file should be excluded based on patterns
515    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    /// Get summary statistics across all sessions
525    ///
526    /// # Errors
527    ///
528    /// Returns an error if analysis fails
529    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    /// Get most active agent types across all sessions
554    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    /// Calculate which agents use which tools
569    /// Returns correlations sorted by usage count (descending)
570    #[must_use]
571    pub fn calculate_agent_tool_correlations(
572        &self,
573        tool_invocations: &[ToolInvocation],
574    ) -> Vec<AgentToolCorrelation> {
575        // Group tools by agent and tool name
576        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        // Calculate total tool usage per agent
602        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        // Convert to correlations and calculate percentages
608        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        // Sort by usage count descending
637        correlations.sort_by_key(|c| std::cmp::Reverse(c.usage_count));
638
639        correlations
640    }
641
642    /// Calculate comprehensive tool statistics
643    #[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            // Extract common command patterns (first 100 chars of command)
688            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        // Convert to IndexMap for ordered results
697        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        // Sort by total invocations descending
718        #[allow(clippy::unnecessary_sort_by)]
719        stats.sort_by(|_, v1, _, v2| v2.total_invocations.cmp(&v1.total_invocations));
720
721        stats
722    }
723
724    /// Calculate category breakdown
725    #[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        // Convert to IndexMap and sort by count descending
739        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    /// Detect sequential tool usage patterns (tool chains)
747    ///
748    /// # Arguments
749    /// * `tool_invocations` - List of tool invocations to analyze
750    ///
751    /// # Returns
752    /// A vector of `ToolChain` instances representing detected patterns
753    ///
754    /// # Algorithm
755    /// 1. Group tools by session
756    /// 2. Sort by timestamp within each session
757    /// 3. Use sliding windows (2-5 tools) to find sequences
758    /// 4. Group identical sequences across sessions
759    /// 5. Calculate frequency, timing, and success rate
760    /// 6. Filter chains that appear at least twice
761    #[must_use]
762    #[allow(dead_code)] // Will be used when tool chain analysis is exposed in CLI
763    pub fn detect_tool_chains(
764        &self,
765        tool_invocations: &[ToolInvocation],
766    ) -> Vec<crate::models::ToolChain> {
767        use crate::models::ToolChain;
768
769        // Group tools by session
770        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        // Track sequences: (tool_names) -> SequenceData
779        let mut sequence_map: HashMap<Vec<String>, SequenceData> = HashMap::new();
780
781        // Maximum time window between tools in a chain (1 hour = 3,600,000 ms)
782        const MAX_TIME_BETWEEN_TOOLS_MS: u64 = 3_600_000;
783
784        // Process each session
785        for (_session_id, mut tools) in session_tools {
786            // Sort by timestamp
787            tools.sort_by_key(|t| t.timestamp);
788
789            // Try different window sizes (2 to 5 tools)
790            for window_size in 2..=5.min(tools.len()) {
791                // Extract all consecutive sequences of this size
792                for window in tools.windows(window_size) {
793                    // Check if tools are within time window
794                    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; // Tools too far apart, skip
806                    }
807
808                    // Extract tool names
809                    let tool_names: Vec<String> =
810                        window.iter().map(|t| t.tool_name.clone()).collect();
811
812                    // Get agent context (use first tool's agent)
813                    let agent = window[0].agent_context.clone();
814
815                    // Calculate time between tools
816                    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                    // Calculate success rate
826                    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        // Convert to ToolChain instances
846        let mut chains: Vec<ToolChain> = sequence_map
847            .into_iter()
848            .filter(|(_, data)| data.frequency >= 2) // Must appear at least twice
849            .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                // Find most common agent
858                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        // Sort by frequency descending
882        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
895/// Helper struct for tracking agent-tool usage data
896struct AgentToolData {
897    usage_count: u32,
898    success_count: u32,
899    failure_count: u32,
900    session_count: HashSet<String>,
901}
902
903/// Helper struct for tracking tool statistics data
904struct 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/// Helper struct for tracking tool chain sequence data
917#[allow(dead_code)] // Used in tool chain detection
918struct 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)] // Used in tool chain detection
927impl 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); // 2 unique agent-tool pairs
1036        assert_eq!(correlations[0].usage_count, 2); // developer-npm should be first (highest usage)
1037        assert_eq!(correlations[0].agent_type, "developer");
1038        assert_eq!(correlations[0].tool_name, "npm");
1039        assert_eq!(correlations[0].success_rate, 1.0); // Both npm calls succeeded
1040        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); // Only npm
1085        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        // BuildTool should be first (2 invocations)
1147        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        // Create a chain that appears twice: cargo build -> cargo test
1167        let tool_invocations = vec![
1168            // First occurrence
1169            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            // Second occurrence
1194            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        // Find the chain with the exact pattern we're looking for
1225        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        // With 4 tools, we get 3 overlapping windows: [0,1], [1,2], [2,3]
1232        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        // Create deployment pipeline: npm install -> npm build -> wrangler deploy
1251        // Appears twice across different sessions
1252        let tool_invocations = vec![
1253            // Session 1 - First occurrence
1254            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            // Session 2 - Second occurrence
1291            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        // Find the 3-tool chain (npm -> npm -> wrangler)
1338        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        // Create a chain that appears only once
1365        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        // Should be empty because chain appears only once
1395        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        // Create tools that are too far apart (> 1 hour)
1414        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        // Should be empty because tools are too far apart
1444        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        // Create a chain with mixed success: 2 occurrences, 1 success, 1 failure
1464        let tool_invocations = vec![
1465            // First occurrence - success
1466            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            // Second occurrence - failure on test
1491            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        // Find the cargo->cargo chain
1522        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        // With overlapping windows, we have:
1530        // Window [0,1]: cargo(exit 0) + cargo(exit 0) = 2 success, 0 failure
1531        // Window [1,2]: cargo(exit 0) + cargo(exit 0) = 2 success, 0 failure
1532        // Window [2,3]: cargo(exit 0) + cargo(exit 1) = 1 success, 1 failure
1533        // Total: 5 successes out of 6 tools = 0.833...
1534        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}