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(|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    /// 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(|a, b| b.1.cmp(&a.1));
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(|a, b| b.usage_count.cmp(&a.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        stats.sort_by(|_, v1, _, v2| v2.total_invocations.cmp(&v1.total_invocations));
719
720        stats
721    }
722
723    /// Calculate category breakdown
724    #[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        // Convert to IndexMap and sort by count descending
738        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    /// Detect sequential tool usage patterns (tool chains)
745    ///
746    /// # Arguments
747    /// * `tool_invocations` - List of tool invocations to analyze
748    ///
749    /// # Returns
750    /// A vector of `ToolChain` instances representing detected patterns
751    ///
752    /// # Algorithm
753    /// 1. Group tools by session
754    /// 2. Sort by timestamp within each session
755    /// 3. Use sliding windows (2-5 tools) to find sequences
756    /// 4. Group identical sequences across sessions
757    /// 5. Calculate frequency, timing, and success rate
758    /// 6. Filter chains that appear at least twice
759    #[must_use]
760    #[allow(dead_code)] // Will be used when tool chain analysis is exposed in CLI
761    pub fn detect_tool_chains(
762        &self,
763        tool_invocations: &[ToolInvocation],
764    ) -> Vec<crate::models::ToolChain> {
765        use crate::models::ToolChain;
766
767        // Group tools by session
768        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        // Track sequences: (tool_names) -> SequenceData
777        let mut sequence_map: HashMap<Vec<String>, SequenceData> = HashMap::new();
778
779        // Maximum time window between tools in a chain (1 hour = 3,600,000 ms)
780        const MAX_TIME_BETWEEN_TOOLS_MS: u64 = 3_600_000;
781
782        // Process each session
783        for (_session_id, mut tools) in session_tools {
784            // Sort by timestamp
785            tools.sort_by_key(|t| t.timestamp);
786
787            // Try different window sizes (2 to 5 tools)
788            for window_size in 2..=5.min(tools.len()) {
789                // Extract all consecutive sequences of this size
790                for window in tools.windows(window_size) {
791                    // Check if tools are within time window
792                    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; // Tools too far apart, skip
804                    }
805
806                    // Extract tool names
807                    let tool_names: Vec<String> =
808                        window.iter().map(|t| t.tool_name.clone()).collect();
809
810                    // Get agent context (use first tool's agent)
811                    let agent = window[0].agent_context.clone();
812
813                    // Calculate time between tools
814                    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                    // Calculate success rate
824                    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        // Convert to ToolChain instances
844        let mut chains: Vec<ToolChain> = sequence_map
845            .into_iter()
846            .filter(|(_, data)| data.frequency >= 2) // Must appear at least twice
847            .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                // Find most common agent
856                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        // Sort by frequency descending
880        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
893/// Helper struct for tracking agent-tool usage data
894struct AgentToolData {
895    usage_count: u32,
896    success_count: u32,
897    failure_count: u32,
898    session_count: HashSet<String>,
899}
900
901/// Helper struct for tracking tool statistics data
902struct 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/// Helper struct for tracking tool chain sequence data
915#[allow(dead_code)] // Used in tool chain detection
916struct 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)] // Used in tool chain detection
925impl 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); // 2 unique agent-tool pairs
1034        assert_eq!(correlations[0].usage_count, 2); // developer-npm should be first (highest usage)
1035        assert_eq!(correlations[0].agent_type, "developer");
1036        assert_eq!(correlations[0].tool_name, "npm");
1037        assert_eq!(correlations[0].success_rate, 1.0); // Both npm calls succeeded
1038        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); // Only npm
1083        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        // BuildTool should be first (2 invocations)
1145        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        // Create a chain that appears twice: cargo build -> cargo test
1165        let tool_invocations = vec![
1166            // First occurrence
1167            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            // Second occurrence
1192            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        // Find the chain with the exact pattern we're looking for
1223        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        // With 4 tools, we get 3 overlapping windows: [0,1], [1,2], [2,3]
1230        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        // Create deployment pipeline: npm install -> npm build -> wrangler deploy
1249        // Appears twice across different sessions
1250        let tool_invocations = vec![
1251            // Session 1 - First occurrence
1252            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            // Session 2 - Second occurrence
1289            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        // Find the 3-tool chain (npm -> npm -> wrangler)
1336        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        // Create a chain that appears only once
1363        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        // Should be empty because chain appears only once
1393        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        // Create tools that are too far apart (> 1 hour)
1412        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        // Should be empty because tools are too far apart
1442        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        // Create a chain with mixed success: 2 occurrences, 1 success, 1 failure
1462        let tool_invocations = vec![
1463            // First occurrence - success
1464            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            // Second occurrence - failure on test
1489            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        // Find the cargo->cargo chain
1520        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        // With overlapping windows, we have:
1528        // Window [0,1]: cargo(exit 0) + cargo(exit 0) = 2 success, 0 failure
1529        // Window [1,2]: cargo(exit 0) + cargo(exit 0) = 2 success, 0 failure
1530        // Window [2,3]: cargo(exit 0) + cargo(exit 1) = 1 success, 1 failure
1531        // Total: 5 successes out of 6 tools = 0.833...
1532        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}