omni_dev/claude/context/
patterns.rs

1//! Work pattern detection and analysis
2
3use crate::data::context::{
4    ArchitecturalImpact, ChangeSignificance, CommitRangeContext, ScopeAnalysis, WorkPattern,
5};
6use crate::git::CommitInfo;
7use std::collections::HashMap;
8use std::path::PathBuf;
9
10/// Work pattern analyzer for commit ranges
11pub struct WorkPatternAnalyzer;
12
13impl WorkPatternAnalyzer {
14    /// Analyze a range of commits to detect work patterns
15    pub fn analyze_commit_range(commits: &[CommitInfo]) -> CommitRangeContext {
16        let mut context = CommitRangeContext::default();
17
18        if commits.is_empty() {
19            return context;
20        }
21
22        // Collect basic information
23        context.related_commits = commits.iter().map(|c| c.hash.clone()).collect();
24        context.common_files = Self::find_common_files(commits);
25
26        // Analyze work pattern
27        context.work_pattern = Self::detect_work_pattern(commits);
28
29        // Analyze scope consistency
30        context.scope_consistency = Self::analyze_scope_consistency(commits);
31
32        // Determine architectural impact
33        context.architectural_impact = Self::determine_architectural_impact(commits);
34
35        // Determine change significance
36        context.change_significance = Self::determine_change_significance(commits);
37
38        context
39    }
40
41    /// Find files that appear in multiple commits
42    fn find_common_files(commits: &[CommitInfo]) -> Vec<PathBuf> {
43        let mut file_counts: HashMap<String, usize> = HashMap::new();
44
45        for commit in commits {
46            for file_change in &commit.analysis.file_changes.file_list {
47                *file_counts.entry(file_change.file.clone()).or_insert(0) += 1;
48            }
49        }
50
51        // Return files that appear in more than one commit or are significant
52        file_counts
53            .into_iter()
54            .filter(|(_, count)| *count > 1 || commits.len() == 1)
55            .map(|(file, _)| PathBuf::from(file))
56            .collect()
57    }
58
59    /// Detect the overall work pattern across commits
60    fn detect_work_pattern(commits: &[CommitInfo]) -> WorkPattern {
61        if commits.len() == 1 {
62            return Self::detect_single_commit_pattern(&commits[0]);
63        }
64
65        let commit_messages: Vec<&str> = commits
66            .iter()
67            .map(|c| c.original_message.as_str())
68            .collect();
69
70        // Check for refactoring patterns
71        if Self::is_refactoring_pattern(&commit_messages) {
72            return WorkPattern::Refactoring;
73        }
74
75        // Check for documentation patterns
76        if Self::is_documentation_pattern(&commit_messages) {
77            return WorkPattern::Documentation;
78        }
79
80        // Check for bug hunt patterns
81        if Self::is_bug_hunt_pattern(&commit_messages) {
82            return WorkPattern::BugHunt;
83        }
84
85        // Check for configuration patterns
86        if Self::is_configuration_pattern(commits) {
87            return WorkPattern::Configuration;
88        }
89
90        // Default to sequential development
91        WorkPattern::Sequential
92    }
93
94    /// Detect pattern for a single commit
95    fn detect_single_commit_pattern(commit: &CommitInfo) -> WorkPattern {
96        let message_lower = commit.original_message.to_lowercase();
97        let file_changes = &commit.analysis.file_changes;
98
99        // Documentation pattern
100        if message_lower.contains("doc")
101            || file_changes
102                .file_list
103                .iter()
104                .any(|f| f.file.ends_with(".md") || f.file.contains("doc"))
105        {
106            return WorkPattern::Documentation;
107        }
108
109        // Configuration pattern
110        if message_lower.contains("config")
111            || file_changes
112                .file_list
113                .iter()
114                .any(|f| is_config_file(&f.file))
115        {
116            return WorkPattern::Configuration;
117        }
118
119        // Refactoring pattern
120        if message_lower.contains("refactor") || message_lower.contains("cleanup") {
121            return WorkPattern::Refactoring;
122        }
123
124        // Bug fix pattern
125        if message_lower.contains("fix") || message_lower.contains("bug") {
126            return WorkPattern::BugHunt;
127        }
128
129        WorkPattern::Sequential
130    }
131
132    /// Check if commits follow a refactoring pattern
133    fn is_refactoring_pattern(messages: &[&str]) -> bool {
134        let refactor_keywords = [
135            "refactor",
136            "cleanup",
137            "reorganize",
138            "restructure",
139            "simplify",
140        ];
141        let refactor_count = messages
142            .iter()
143            .filter(|msg| {
144                let msg_lower = msg.to_lowercase();
145                refactor_keywords
146                    .iter()
147                    .any(|keyword| msg_lower.contains(keyword))
148            })
149            .count();
150
151        refactor_count as f32 / messages.len() as f32 > 0.5
152    }
153
154    /// Check if commits follow a documentation pattern
155    fn is_documentation_pattern(messages: &[&str]) -> bool {
156        let doc_keywords = ["doc", "readme", "comment", "guide", "manual"];
157        let doc_count = messages
158            .iter()
159            .filter(|msg| {
160                let msg_lower = msg.to_lowercase();
161                doc_keywords
162                    .iter()
163                    .any(|keyword| msg_lower.contains(keyword))
164            })
165            .count();
166
167        doc_count as f32 / messages.len() as f32 > 0.6
168    }
169
170    /// Check if commits follow a bug hunting pattern
171    fn is_bug_hunt_pattern(messages: &[&str]) -> bool {
172        let bug_keywords = ["fix", "bug", "issue", "error", "problem", "debug"];
173        let bug_count = messages
174            .iter()
175            .filter(|msg| {
176                let msg_lower = msg.to_lowercase();
177                bug_keywords
178                    .iter()
179                    .any(|keyword| msg_lower.contains(keyword))
180            })
181            .count();
182
183        bug_count as f32 / messages.len() as f32 > 0.4
184    }
185
186    /// Check if commits follow a configuration pattern
187    fn is_configuration_pattern(commits: &[CommitInfo]) -> bool {
188        let config_file_count = commits
189            .iter()
190            .filter(|commit| {
191                commit
192                    .analysis
193                    .file_changes
194                    .file_list
195                    .iter()
196                    .any(|f| is_config_file(&f.file))
197            })
198            .count();
199
200        config_file_count as f32 / commits.len() as f32 > 0.5
201    }
202
203    /// Analyze consistency of scopes across commits
204    fn analyze_scope_consistency(commits: &[CommitInfo]) -> ScopeAnalysis {
205        let mut scope_counts: HashMap<String, usize> = HashMap::new();
206        let mut detected_scopes = Vec::new();
207
208        for commit in commits {
209            let scope = &commit.analysis.detected_scope;
210            if !scope.is_empty() {
211                *scope_counts.entry(scope.clone()).or_insert(0) += 1;
212                detected_scopes.push(scope.clone());
213            }
214        }
215
216        let consistent_scope = scope_counts
217            .iter()
218            .max_by_key(|(_, count)| *count)
219            .map(|(scope, _)| scope.clone());
220
221        let confidence = if let Some(ref scope) = consistent_scope {
222            let scope_count = scope_counts.get(scope).unwrap_or(&0);
223            *scope_count as f32 / commits.len() as f32
224        } else {
225            0.0
226        };
227
228        ScopeAnalysis {
229            consistent_scope,
230            scope_changes: detected_scopes,
231            confidence,
232        }
233    }
234
235    /// Determine the architectural impact of the commit range
236    fn determine_architectural_impact(commits: &[CommitInfo]) -> ArchitecturalImpact {
237        let total_files_changed: usize = commits
238            .iter()
239            .map(|c| c.analysis.file_changes.total_files)
240            .sum();
241
242        let has_critical_files = commits.iter().any(|commit| {
243            commit
244                .analysis
245                .file_changes
246                .file_list
247                .iter()
248                .any(|f| is_critical_file(&f.file))
249        });
250
251        let has_breaking_changes = commits.iter().any(|commit| {
252            commit.analysis.file_changes.files_deleted > 0
253                || commit
254                    .analysis
255                    .file_changes
256                    .file_list
257                    .iter()
258                    .any(|f| f.status == "D" && is_public_interface(&f.file))
259        });
260
261        if has_breaking_changes {
262            ArchitecturalImpact::Breaking
263        } else if has_critical_files || total_files_changed > 20 {
264            ArchitecturalImpact::Significant
265        } else if total_files_changed > 5 {
266            ArchitecturalImpact::Moderate
267        } else {
268            ArchitecturalImpact::Minimal
269        }
270    }
271
272    /// Determine the significance of changes for commit message detail
273    fn determine_change_significance(commits: &[CommitInfo]) -> ChangeSignificance {
274        let total_lines_changed: i32 = commits
275            .iter()
276            .map(|commit| {
277                // Estimate lines changed from diff summary
278                estimate_lines_changed(&commit.analysis.diff_summary)
279            })
280            .sum();
281
282        let has_new_features = commits.iter().any(|commit| {
283            let msg_lower = commit.original_message.to_lowercase();
284            msg_lower.contains("feat")
285                || msg_lower.contains("add")
286                || msg_lower.contains("implement")
287        });
288
289        let has_major_files = commits.iter().any(|commit| {
290            commit
291                .analysis
292                .file_changes
293                .file_list
294                .iter()
295                .any(|f| is_critical_file(&f.file))
296        });
297
298        if total_lines_changed > 500 || has_major_files {
299            ChangeSignificance::Critical
300        } else if total_lines_changed > 100 || has_new_features {
301            ChangeSignificance::Major
302        } else if total_lines_changed > 20 {
303            ChangeSignificance::Moderate
304        } else {
305            ChangeSignificance::Minor
306        }
307    }
308}
309
310/// Check if a file is a configuration file
311fn is_config_file(file_path: &str) -> bool {
312    let config_extensions = [".toml", ".json", ".yaml", ".yml", ".ini", ".cfg"];
313    let config_names = ["Cargo.toml", "package.json", "go.mod", "pom.xml"];
314
315    config_extensions.iter().any(|ext| file_path.ends_with(ext))
316        || config_names.iter().any(|name| file_path.contains(name))
317}
318
319/// Check if a file is critical to the project
320fn is_critical_file(file_path: &str) -> bool {
321    let critical_files = [
322        "main.rs",
323        "lib.rs",
324        "index.js",
325        "main.py",
326        "main.go",
327        "Cargo.toml",
328        "package.json",
329        "go.mod",
330        "pom.xml",
331    ];
332
333    critical_files.iter().any(|name| file_path.contains(name))
334        || file_path.contains("src/lib.rs")
335        || file_path.contains("src/main.rs")
336}
337
338/// Check if a file is part of public interface
339fn is_public_interface(file_path: &str) -> bool {
340    file_path.contains("lib.rs")
341        || file_path.contains("mod.rs")
342        || file_path.contains("api")
343        || file_path.contains("interface")
344        || file_path.ends_with(".proto")
345        || file_path.ends_with(".graphql")
346}
347
348/// Estimate lines changed from diff summary
349fn estimate_lines_changed(diff_summary: &str) -> i32 {
350    let mut total = 0;
351
352    for line in diff_summary.lines() {
353        if let Some(changes_part) = line.split('|').nth(1) {
354            if let Some(numbers_part) = changes_part.split_whitespace().next() {
355                if let Ok(num) = numbers_part.parse::<i32>() {
356                    total += num;
357                }
358            }
359        }
360    }
361
362    total
363}