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