omni_dev/claude/context/
patterns.rs1use crate::data::context::{
4 ArchitecturalImpact, ChangeSignificance, CommitRangeContext, ScopeAnalysis, WorkPattern,
5};
6use crate::git::CommitInfo;
7use std::collections::HashMap;
8use std::path::PathBuf;
9
10pub struct WorkPatternAnalyzer;
12
13impl WorkPatternAnalyzer {
14 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 context.related_commits = commits.iter().map(|c| c.hash.clone()).collect();
24 context.common_files = Self::find_common_files(commits);
25
26 context.work_pattern = Self::detect_work_pattern(commits);
28
29 context.scope_consistency = Self::analyze_scope_consistency(commits);
31
32 context.architectural_impact = Self::determine_architectural_impact(commits);
34
35 context.change_significance = Self::determine_change_significance(commits);
37
38 context
39 }
40
41 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 file_counts
53 .into_iter()
54 .filter(|(_, count)| *count > 1 || commits.len() == 1)
55 .map(|(file, _)| PathBuf::from(file))
56 .collect()
57 }
58
59 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 if Self::is_refactoring_pattern(&commit_messages) {
72 return WorkPattern::Refactoring;
73 }
74
75 if Self::is_documentation_pattern(&commit_messages) {
77 return WorkPattern::Documentation;
78 }
79
80 if Self::is_bug_hunt_pattern(&commit_messages) {
82 return WorkPattern::BugHunt;
83 }
84
85 if Self::is_configuration_pattern(commits) {
87 return WorkPattern::Configuration;
88 }
89
90 WorkPattern::Sequential
92 }
93
94 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 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 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 if message_lower.contains("refactor") || message_lower.contains("cleanup") {
121 return WorkPattern::Refactoring;
122 }
123
124 if message_lower.contains("fix") || message_lower.contains("bug") {
126 return WorkPattern::BugHunt;
127 }
128
129 WorkPattern::Sequential
130 }
131
132 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 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 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 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 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 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 fn determine_change_significance(commits: &[CommitInfo]) -> ChangeSignificance {
274 let total_lines_changed: i32 = commits
275 .iter()
276 .map(|commit| {
277 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
310fn 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
319fn 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
338fn 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
348fn 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}