omni_dev/claude/context/
patterns.rs1use std::collections::HashMap;
4use std::path::PathBuf;
5
6use crate::data::context::{
7 ArchitecturalImpact, ChangeSignificance, CommitRangeContext, ScopeAnalysis, WorkPattern,
8};
9use crate::git::CommitInfo;
10
11pub struct WorkPatternAnalyzer;
13
14impl WorkPatternAnalyzer {
15 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 context.related_commits = commits.iter().map(|c| c.hash.clone()).collect();
25 context.common_files = Self::find_common_files(commits);
26
27 context.work_pattern = Self::detect_work_pattern(commits);
29
30 context.scope_consistency = Self::analyze_scope_consistency(commits);
32
33 context.architectural_impact = Self::determine_architectural_impact(commits);
35
36 context.change_significance = Self::determine_change_significance(commits);
38
39 context
40 }
41
42 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 file_counts
54 .into_iter()
55 .filter(|(_, count)| *count > 1 || commits.len() == 1)
56 .map(|(file, _)| PathBuf::from(file))
57 .collect()
58 }
59
60 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 if Self::is_refactoring_pattern(&commit_messages) {
73 return WorkPattern::Refactoring;
74 }
75
76 if Self::is_documentation_pattern(&commit_messages) {
78 return WorkPattern::Documentation;
79 }
80
81 if Self::is_bug_hunt_pattern(&commit_messages) {
83 return WorkPattern::BugHunt;
84 }
85
86 if Self::is_configuration_pattern(commits) {
88 return WorkPattern::Configuration;
89 }
90
91 WorkPattern::Sequential
93 }
94
95 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 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 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 if message_lower.contains("refactor") || message_lower.contains("cleanup") {
122 return WorkPattern::Refactoring;
123 }
124
125 if message_lower.contains("fix") || message_lower.contains("bug") {
127 return WorkPattern::BugHunt;
128 }
129
130 WorkPattern::Sequential
131 }
132
133 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 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 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 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 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 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 fn determine_change_significance(commits: &[CommitInfo]) -> ChangeSignificance {
275 let total_lines_changed: i32 = commits
276 .iter()
277 .map(|commit| {
278 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
311fn 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
320fn 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
339fn 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
349fn 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}