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}
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    // ── is_config_file ─────────────────────────────────────────────
408
409    #[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    // ── is_critical_file ───────────────────────────────────────────
430
431    #[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    // ── is_public_interface ────────────────────────────────────────
452
453    #[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    // ── estimate_lines_changed ─────────────────────────────────────
474
475    #[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    // ── detect_single_commit_pattern ───────────────────────────────
497
498    #[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    // ── multi-commit pattern detection ─────────────────────────────
544
545    #[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    // ── scope consistency analysis ─────────────────────────────────
586
587    #[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    // ── architectural impact ───────────────────────────────────────
626
627    #[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    // ── change significance ────────────────────────────────────────
649
650    #[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    // ── analyze_commit_range integration ───────────────────────────
672
673    #[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        // src/main.rs appears in both commits
696        assert!(context
697            .common_files
698            .iter()
699            .any(|f| f.to_string_lossy() == "src/main.rs"));
700    }
701
702    // ── property tests ────────────────────────────────────────────
703
704    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}