Skip to main content

omni_dev/claude/context/
patterns.rs

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