1use 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}
365
366#[cfg(test)]
367mod tests {
368 use super::*;
369 use crate::git::commit::{CommitAnalysis, CommitInfo, FileChange, FileChanges};
370
371 fn make_commit(message: &str, files: Vec<(&str, &str)>) -> CommitInfo {
372 CommitInfo {
373 hash: "a".repeat(40),
374 author: "Test <test@test.com>".to_string(),
375 date: chrono::Utc::now().fixed_offset(),
376 original_message: message.to_string(),
377 in_main_branches: Vec::new(),
378 analysis: CommitAnalysis {
379 detected_type: String::new(),
380 detected_scope: String::new(),
381 proposed_message: String::new(),
382 file_changes: FileChanges {
383 total_files: files.len(),
384 files_added: files.iter().filter(|(s, _)| *s == "A").count(),
385 files_deleted: files.iter().filter(|(s, _)| *s == "D").count(),
386 file_list: files
387 .into_iter()
388 .map(|(status, file)| FileChange {
389 status: status.to_string(),
390 file: file.to_string(),
391 })
392 .collect(),
393 },
394 diff_summary: String::new(),
395 diff_file: String::new(),
396 file_diffs: Vec::new(),
397 },
398 }
399 }
400
401 fn make_commit_with_scope(message: &str, scope: &str) -> CommitInfo {
402 let mut commit = make_commit(message, vec![]);
403 commit.analysis.detected_scope = scope.to_string();
404 commit
405 }
406
407 #[test]
410 fn config_file_toml() {
411 assert!(is_config_file("Cargo.toml"));
412 }
413
414 #[test]
415 fn config_file_json() {
416 assert!(is_config_file("package.json"));
417 }
418
419 #[test]
420 fn config_file_yaml() {
421 assert!(is_config_file("config.yaml"));
422 }
423
424 #[test]
425 fn not_config_file_rs() {
426 assert!(!is_config_file("src/main.rs"));
427 }
428
429 #[test]
432 fn critical_file_main_rs() {
433 assert!(is_critical_file("src/main.rs"));
434 }
435
436 #[test]
437 fn critical_file_lib_rs() {
438 assert!(is_critical_file("src/lib.rs"));
439 }
440
441 #[test]
442 fn critical_file_cargo_toml() {
443 assert!(is_critical_file("Cargo.toml"));
444 }
445
446 #[test]
447 fn not_critical_file_helper() {
448 assert!(!is_critical_file("src/utils/helper.rs"));
449 }
450
451 #[test]
454 fn public_interface_lib_rs() {
455 assert!(is_public_interface("src/lib.rs"));
456 }
457
458 #[test]
459 fn public_interface_mod_rs() {
460 assert!(is_public_interface("src/cli/mod.rs"));
461 }
462
463 #[test]
464 fn public_interface_proto() {
465 assert!(is_public_interface("api/service.proto"));
466 }
467
468 #[test]
469 fn not_public_interface_internal() {
470 assert!(!is_public_interface("src/utils/helper.rs"));
471 }
472
473 #[test]
476 fn estimate_lines_empty() {
477 assert_eq!(estimate_lines_changed(""), 0);
478 }
479
480 #[test]
481 fn estimate_lines_single_file() {
482 assert_eq!(estimate_lines_changed(" src/main.rs | 10 ++++"), 10);
483 }
484
485 #[test]
486 fn estimate_lines_multiple_files() {
487 let summary = " src/main.rs | 10 ++++\n src/lib.rs | 5 ++";
488 assert_eq!(estimate_lines_changed(summary), 15);
489 }
490
491 #[test]
492 fn estimate_lines_no_numbers() {
493 assert_eq!(estimate_lines_changed("no pipe here"), 0);
494 }
495
496 #[test]
499 fn single_commit_doc_pattern() {
500 let commit = make_commit("Update README", vec![("M", "README.md")]);
501 assert!(matches!(
502 WorkPatternAnalyzer::detect_work_pattern(&[commit]),
503 WorkPattern::Documentation
504 ));
505 }
506
507 #[test]
508 fn single_commit_config_pattern() {
509 let commit = make_commit("Update config", vec![("M", "settings.toml")]);
510 assert!(matches!(
511 WorkPatternAnalyzer::detect_work_pattern(&[commit]),
512 WorkPattern::Configuration
513 ));
514 }
515
516 #[test]
517 fn single_commit_refactor_pattern() {
518 let commit = make_commit("refactor: simplify logic", vec![("M", "src/core.rs")]);
519 assert!(matches!(
520 WorkPatternAnalyzer::detect_work_pattern(&[commit]),
521 WorkPattern::Refactoring
522 ));
523 }
524
525 #[test]
526 fn single_commit_bugfix_pattern() {
527 let commit = make_commit("fix: resolve crash", vec![("M", "src/handler.rs")]);
528 assert!(matches!(
529 WorkPatternAnalyzer::detect_work_pattern(&[commit]),
530 WorkPattern::BugHunt
531 ));
532 }
533
534 #[test]
535 fn single_commit_sequential_default() {
536 let commit = make_commit("feat: add feature", vec![("A", "src/new.rs")]);
537 assert!(matches!(
538 WorkPatternAnalyzer::detect_work_pattern(&[commit]),
539 WorkPattern::Sequential
540 ));
541 }
542
543 #[test]
546 fn multi_commit_refactoring_pattern() {
547 let commits = vec![
548 make_commit("refactor: extract module", vec![]),
549 make_commit("cleanup: remove dead code", vec![]),
550 make_commit("simplify: reduce complexity", vec![]),
551 ];
552 assert!(matches!(
553 WorkPatternAnalyzer::detect_work_pattern(&commits),
554 WorkPattern::Refactoring
555 ));
556 }
557
558 #[test]
559 fn multi_commit_documentation_pattern() {
560 let commits = vec![
561 make_commit("doc: add API guide", vec![]),
562 make_commit("docs: update readme", vec![]),
563 make_commit("readme: add examples", vec![]),
564 make_commit("manual: update install guide", vec![]),
565 ];
566 assert!(matches!(
567 WorkPatternAnalyzer::detect_work_pattern(&commits),
568 WorkPattern::Documentation
569 ));
570 }
571
572 #[test]
573 fn multi_commit_bug_hunt_pattern() {
574 let commits = vec![
575 make_commit("fix: null pointer", vec![]),
576 make_commit("debug: add logging", vec![]),
577 make_commit("fix: race condition", vec![]),
578 ];
579 assert!(matches!(
580 WorkPatternAnalyzer::detect_work_pattern(&commits),
581 WorkPattern::BugHunt
582 ));
583 }
584
585 #[test]
588 fn scope_consistency_all_same() {
589 let commits = vec![
590 make_commit_with_scope("feat(cli): add flag", "cli"),
591 make_commit_with_scope("fix(cli): fix bug", "cli"),
592 ];
593 let analysis = WorkPatternAnalyzer::analyze_scope_consistency(&commits);
594 assert_eq!(analysis.consistent_scope, Some("cli".to_string()));
595 assert!(
596 (analysis.confidence - 1.0).abs() < f32::EPSILON,
597 "confidence should be 1.0 for consistent scope"
598 );
599 }
600
601 #[test]
602 fn scope_consistency_mixed() {
603 let commits = vec![
604 make_commit_with_scope("feat(cli): add flag", "cli"),
605 make_commit_with_scope("fix(git): fix bug", "git"),
606 make_commit_with_scope("feat(cli): another", "cli"),
607 ];
608 let analysis = WorkPatternAnalyzer::analyze_scope_consistency(&commits);
609 assert_eq!(analysis.consistent_scope, Some("cli".to_string()));
610 }
611
612 #[test]
613 fn scope_consistency_empty_scopes() {
614 let commits = vec![
615 make_commit_with_scope("update stuff", ""),
616 make_commit_with_scope("more stuff", ""),
617 ];
618 let analysis = WorkPatternAnalyzer::analyze_scope_consistency(&commits);
619 assert!(
620 analysis.confidence.abs() < f32::EPSILON,
621 "confidence should be 0.0 for empty scopes"
622 );
623 }
624
625 #[test]
628 fn architectural_impact_breaking() {
629 let commit = make_commit("remove API", vec![("D", "src/lib.rs")]);
630 let impact = WorkPatternAnalyzer::determine_architectural_impact(&[commit]);
631 assert!(matches!(impact, ArchitecturalImpact::Breaking));
632 }
633
634 #[test]
635 fn architectural_impact_significant_critical_files() {
636 let commit = make_commit("update main", vec![("M", "src/main.rs")]);
637 let impact = WorkPatternAnalyzer::determine_architectural_impact(&[commit]);
638 assert!(matches!(impact, ArchitecturalImpact::Significant));
639 }
640
641 #[test]
642 fn architectural_impact_minimal() {
643 let commit = make_commit("small fix", vec![("M", "src/utils/helper.rs")]);
644 let impact = WorkPatternAnalyzer::determine_architectural_impact(&[commit]);
645 assert!(matches!(impact, ArchitecturalImpact::Minimal));
646 }
647
648 #[test]
651 fn change_significance_critical_with_major_files() {
652 let commit = make_commit("big change", vec![("M", "src/main.rs")]);
653 let significance = WorkPatternAnalyzer::determine_change_significance(&[commit]);
654 assert!(matches!(significance, ChangeSignificance::Critical));
655 }
656
657 #[test]
658 fn change_significance_major_with_feat() {
659 let commit = make_commit("feat: add new feature", vec![("A", "src/new.rs")]);
660 let significance = WorkPatternAnalyzer::determine_change_significance(&[commit]);
661 assert!(matches!(significance, ChangeSignificance::Major));
662 }
663
664 #[test]
665 fn change_significance_minor_small_change() {
666 let commit = make_commit("tweak", vec![("M", "src/utils/helper.rs")]);
667 let significance = WorkPatternAnalyzer::determine_change_significance(&[commit]);
668 assert!(matches!(significance, ChangeSignificance::Minor));
669 }
670
671 #[test]
674 fn analyze_commit_range_empty() {
675 let context = WorkPatternAnalyzer::analyze_commit_range(&[]);
676 assert!(context.related_commits.is_empty());
677 assert!(context.common_files.is_empty());
678 }
679
680 #[test]
681 fn analyze_commit_range_single_commit() {
682 let commit = make_commit("feat: add feature", vec![("A", "src/new.rs")]);
683 let context = WorkPatternAnalyzer::analyze_commit_range(&[commit]);
684 assert_eq!(context.related_commits.len(), 1);
685 assert_eq!(context.common_files.len(), 1);
686 }
687
688 #[test]
689 fn analyze_commit_range_common_files() {
690 let commits = vec![
691 make_commit("first", vec![("M", "src/main.rs"), ("M", "src/lib.rs")]),
692 make_commit("second", vec![("M", "src/main.rs")]),
693 ];
694 let context = WorkPatternAnalyzer::analyze_commit_range(&commits);
695 assert!(context
697 .common_files
698 .iter()
699 .any(|f| f.to_string_lossy() == "src/main.rs"));
700 }
701
702 mod prop {
705 use super::*;
706 use proptest::prelude::*;
707
708 proptest! {
709 #[test]
710 fn estimate_lines_nonnegative(s in ".*") {
711 prop_assert!(estimate_lines_changed(&s) >= 0);
712 }
713
714 #[test]
715 fn estimate_lines_structured_input(n in 0_u16..10000) {
716 let input = format!(" src/main.rs | {n} ++++");
717 let result = estimate_lines_changed(&input);
718 prop_assert!(result >= i32::from(n));
719 }
720
721 #[test]
722 fn classification_deterministic(s in ".*") {
723 prop_assert_eq!(is_config_file(&s), is_config_file(&s));
724 prop_assert_eq!(is_critical_file(&s), is_critical_file(&s));
725 prop_assert_eq!(is_public_interface(&s), is_public_interface(&s));
726 }
727 }
728 }
729}