1use chrono::{DateTime, Local};
6use std::collections::HashMap;
7
8use crate::event::GitEvent;
9
10#[derive(Debug, Clone)]
16pub struct CommitQualityScore {
17 pub commit_hash: String,
19 pub commit_message: String,
21 pub author: String,
23 pub date: DateTime<Local>,
25 pub files_changed: usize,
27 pub insertions: usize,
29 pub deletions: usize,
31 pub score: f64,
33 pub message_score: f64,
35 pub size_score: f64,
37 pub test_score: f64,
39 pub atomicity_score: f64,
41}
42
43impl CommitQualityScore {
44 pub fn score_color(&self) -> &'static str {
46 if self.score >= 0.6 {
47 "green" } else if self.score >= 0.4 {
49 "yellow" } else {
51 "red" }
53 }
54
55 pub fn score_bar(&self) -> &'static str {
57 if self.score >= 0.8 {
58 "█████"
59 } else if self.score >= 0.6 {
60 "████ "
61 } else if self.score >= 0.4 {
62 "███ "
63 } else if self.score >= 0.2 {
64 "██ "
65 } else {
66 "█ "
67 }
68 }
69
70 pub fn quality_level(&self) -> &'static str {
72 if self.score >= 0.8 {
73 "Excellent"
74 } else if self.score >= 0.6 {
75 "Good"
76 } else if self.score >= 0.4 {
77 "Fair"
78 } else {
79 "Poor"
80 }
81 }
82}
83
84#[derive(Debug, Clone, Default)]
86pub struct CommitQualityAnalysis {
87 pub commits: Vec<CommitQualityScore>,
89 pub total_commits: usize,
91 pub avg_score: f64,
93 pub high_quality_count: usize,
95 pub low_quality_count: usize,
97}
98
99impl CommitQualityAnalysis {
100 pub fn commit_count(&self) -> usize {
102 self.commits.len()
103 }
104}
105
106fn calculate_message_quality(message: &str) -> f64 {
113 let mut score = 0.0;
114
115 let conventional_prefixes = [
117 "feat:",
118 "fix:",
119 "docs:",
120 "style:",
121 "refactor:",
122 "test:",
123 "chore:",
124 "perf:",
125 "ci:",
126 "build:",
127 "revert:",
128 ];
129 let has_conventional_prefix = conventional_prefixes
130 .iter()
131 .any(|prefix| message.to_lowercase().starts_with(prefix));
132 if has_conventional_prefix {
133 score += 0.4;
134 }
135
136 let len = message.len();
138 if (10..=72).contains(&len) {
139 score += 0.3;
140 } else if (5..=100).contains(&len) {
141 score += 0.15;
143 }
144
145 let meaningless_patterns = ["update", "fix", "wip", "changes", "commit", "test", "."];
147 let trimmed = message.trim().to_lowercase();
148 let is_meaningful = !meaningless_patterns.contains(&trimmed.as_str())
149 && message.trim().len() > 3
150 && !message.trim().chars().all(|c| !c.is_alphabetic());
151 if is_meaningful {
152 score += 0.3;
153 }
154
155 score
156}
157
158fn calculate_size_appropriateness(
164 files_changed: usize,
165 insertions: usize,
166 deletions: usize,
167) -> f64 {
168 let mut score = 0.0;
169
170 let file_score = if files_changed == 0 {
172 0.0
173 } else if files_changed <= 5 {
174 0.5
175 } else if files_changed <= 10 {
176 0.3
177 } else if files_changed <= 20 {
178 0.1
179 } else {
180 0.0
181 };
182 score += file_score;
183
184 let total_changes = insertions + deletions;
186 let change_score = if total_changes == 0 {
187 0.0
188 } else if (10..=200).contains(&total_changes) {
189 0.5
190 } else if (1..=500).contains(&total_changes) {
191 0.3
192 } else if total_changes <= 1000 {
193 0.1
194 } else {
195 0.0
196 };
197 score += change_score;
198
199 score
200}
201
202fn calculate_test_presence(files: &[String]) -> f64 {
207 if files.is_empty() {
208 return 0.0;
209 }
210
211 let has_source = files.iter().any(|f| {
212 let f_lower = f.to_lowercase();
213 !f_lower.contains("test")
214 && !f_lower.contains("spec")
215 && (f_lower.ends_with(".rs")
216 || f_lower.ends_with(".ts")
217 || f_lower.ends_with(".js")
218 || f_lower.ends_with(".py")
219 || f_lower.ends_with(".go")
220 || f_lower.ends_with(".java")
221 || f_lower.ends_with(".rb")
222 || f_lower.ends_with(".c")
223 || f_lower.ends_with(".cpp")
224 || f_lower.ends_with(".h"))
225 });
226
227 let has_test = files.iter().any(|f| {
228 let f_lower = f.to_lowercase();
229 f_lower.contains("test")
230 || f_lower.contains("spec")
231 || f_lower.contains("_test.")
232 || f_lower.contains(".test.")
233 });
234
235 if has_source && has_test {
236 1.0 } else if has_test {
238 0.8 } else if has_source {
240 0.3 } else {
242 0.5 }
244}
245
246fn calculate_atomicity(files: &[String], coupling: &ChangeCouplingAnalysis) -> f64 {
252 if files.is_empty() {
253 return 0.0;
254 }
255
256 if files.len() == 1 {
257 return 1.0; }
259
260 let dirs: std::collections::HashSet<&str> =
262 files.iter().filter_map(|f| f.rsplit('/').nth(1)).collect();
263 let dir_score = if dirs.len() == 1 {
264 0.5 } else if dirs.len() <= 2 {
266 0.35
267 } else if dirs.len() <= 3 {
268 0.2
269 } else {
270 0.1
271 };
272
273 let mut coupling_sum = 0.0;
276 let mut coupling_count = 0;
277
278 for file in files {
279 for other_file in files {
280 if file != other_file {
281 if let Some(c) = coupling
283 .couplings
284 .iter()
285 .find(|c| &c.file == file && &c.coupled_file == other_file)
286 {
287 coupling_sum += c.coupling_percent;
288 coupling_count += 1;
289 }
290 }
291 }
292 }
293
294 let coupling_score = if coupling_count > 0 {
295 (coupling_sum / coupling_count as f64) * 0.5
296 } else {
297 0.25 };
299
300 dir_score + coupling_score
301}
302
303pub fn calculate_quality_scores(
311 events: &[&GitEvent],
312 get_files: impl Fn(&str) -> Option<Vec<String>>,
313 coupling: &ChangeCouplingAnalysis,
314) -> CommitQualityAnalysis {
315 let mut commits = Vec::new();
316 let mut total_score = 0.0;
317 let mut high_quality_count = 0;
318 let mut low_quality_count = 0;
319
320 for event in events {
321 let files = get_files(&event.short_hash).unwrap_or_default();
322
323 let message_score = calculate_message_quality(&event.message) * 0.30;
325 let size_score =
326 calculate_size_appropriateness(files.len(), event.files_added, event.files_deleted)
327 * 0.25;
328 let test_score = calculate_test_presence(&files) * 0.25;
329 let atomicity_score = calculate_atomicity(&files, coupling) * 0.20;
330
331 let score = message_score + size_score + test_score + atomicity_score;
333
334 commits.push(CommitQualityScore {
335 commit_hash: event.short_hash.clone(),
336 commit_message: event.message.clone(),
337 author: event.author.clone(),
338 date: event.timestamp,
339 files_changed: files.len(),
340 insertions: event.files_added,
341 deletions: event.files_deleted,
342 score,
343 message_score,
344 size_score,
345 test_score,
346 atomicity_score,
347 });
348
349 total_score += score;
350 if score >= 0.6 {
351 high_quality_count += 1;
352 }
353 if score < 0.4 {
354 low_quality_count += 1;
355 }
356 }
357
358 commits.sort_by(|a, b| {
360 b.score
361 .partial_cmp(&a.score)
362 .unwrap_or(std::cmp::Ordering::Equal)
363 });
364
365 let total_commits = commits.len();
366 let avg_score = if total_commits > 0 {
367 total_score / total_commits as f64
368 } else {
369 0.0
370 };
371
372 CommitQualityAnalysis {
373 commits,
374 total_commits,
375 avg_score,
376 high_quality_count,
377 low_quality_count,
378 }
379}
380
381#[derive(Debug, Clone)]
387pub struct CommitImpactScore {
388 pub commit_hash: String,
390 pub commit_message: String,
392 pub author: String,
394 pub date: DateTime<Local>,
396 pub files_changed: usize,
398 pub insertions: usize,
400 pub deletions: usize,
402 pub score: f64,
404 pub file_score: f64,
406 pub change_score: f64,
408 pub heat_score: f64,
410}
411
412impl CommitImpactScore {
413 pub fn score_color(&self) -> &'static str {
415 if self.score >= 0.7 {
416 "red" } else if self.score >= 0.4 {
418 "yellow" } else {
420 "green" }
422 }
423
424 pub fn score_bar(&self) -> &'static str {
426 if self.score >= 0.8 {
427 "█████"
428 } else if self.score >= 0.6 {
429 "████ "
430 } else if self.score >= 0.4 {
431 "███ "
432 } else if self.score >= 0.2 {
433 "██ "
434 } else {
435 "█ "
436 }
437 }
438}
439
440#[derive(Debug, Clone, Default)]
442pub struct CommitImpactAnalysis {
443 pub commits: Vec<CommitImpactScore>,
445 pub total_commits: usize,
447 pub avg_score: f64,
449 pub max_score: f64,
451 pub high_impact_count: usize,
453}
454
455impl CommitImpactAnalysis {
456 pub fn commit_count(&self) -> usize {
458 self.commits.len()
459 }
460}
461
462pub fn calculate_impact_scores(
469 events: &[&GitEvent],
470 get_files: impl Fn(&str) -> Option<Vec<String>>,
471 file_heatmap: &FileHeatmap,
472) -> CommitImpactAnalysis {
473 let mut commits = Vec::new();
474 let mut total_score = 0.0;
475 let mut max_score = 0.0f64;
476 let mut high_impact_count = 0;
477
478 let heat_map: HashMap<&str, f64> = file_heatmap
480 .files
481 .iter()
482 .map(|f| (f.path.as_str(), f.heat_level()))
483 .collect();
484
485 for event in events {
486 let files = get_files(&event.short_hash).unwrap_or_default();
487 let files_changed = files.len();
488 let total_changes = event.files_added + event.files_deleted;
489
490 let file_score = (files_changed as f64 / 50.0).min(1.0) * 0.4;
492
493 let change_score = (total_changes as f64 / 500.0).min(1.0) * 0.4;
495
496 let avg_file_heat = if files.is_empty() {
498 0.0
499 } else {
500 let total_heat: f64 = files
501 .iter()
502 .map(|f| heat_map.get(f.as_str()).copied().unwrap_or(0.0))
503 .sum();
504 total_heat / files.len() as f64
505 };
506 let heat_score = avg_file_heat * 0.2;
507
508 let score = file_score + change_score + heat_score;
510
511 commits.push(CommitImpactScore {
512 commit_hash: event.short_hash.clone(),
513 commit_message: event.message.clone(),
514 author: event.author.clone(),
515 date: event.timestamp,
516 files_changed,
517 insertions: event.files_added,
518 deletions: event.files_deleted,
519 score,
520 file_score,
521 change_score,
522 heat_score,
523 });
524
525 total_score += score;
526 max_score = max_score.max(score);
527 if score >= 0.7 {
528 high_impact_count += 1;
529 }
530 }
531
532 commits.sort_by(|a, b| {
534 b.score
535 .partial_cmp(&a.score)
536 .unwrap_or(std::cmp::Ordering::Equal)
537 });
538
539 let total_commits = commits.len();
540 let avg_score = if total_commits > 0 {
541 total_score / total_commits as f64
542 } else {
543 0.0
544 };
545
546 CommitImpactAnalysis {
547 commits,
548 total_commits,
549 avg_score,
550 max_score,
551 high_impact_count,
552 }
553}
554
555#[derive(Debug, Clone)]
561pub struct FileCoupling {
562 pub file: String,
564 pub coupled_file: String,
566 pub co_change_count: usize,
568 pub file_change_count: usize,
570 pub coupling_percent: f64,
572}
573
574impl FileCoupling {
575 pub fn coupling_bar(&self) -> String {
577 let filled = (self.coupling_percent * 10.0).round() as usize;
578 let empty = 10 - filled;
579 format!("[{}{}]", "█".repeat(filled), "░".repeat(empty))
580 }
581}
582
583#[derive(Debug, Clone, Default)]
585pub struct ChangeCouplingAnalysis {
586 pub couplings: Vec<FileCoupling>,
588 pub high_coupling_count: usize,
590 pub total_files_analyzed: usize,
592}
593
594impl ChangeCouplingAnalysis {
595 pub fn coupling_count(&self) -> usize {
597 self.couplings.len()
598 }
599
600 pub fn grouped_by_file(&self) -> Vec<(&str, Vec<&FileCoupling>)> {
602 use std::collections::HashMap;
603 let mut groups: HashMap<&str, Vec<&FileCoupling>> = HashMap::new();
604 for coupling in &self.couplings {
605 groups.entry(&coupling.file).or_default().push(coupling);
606 }
607 let mut result: Vec<_> = groups.into_iter().collect();
608 result.sort_by(|a, b| {
610 let count_a = a.1.first().map(|c| c.file_change_count).unwrap_or(0);
611 let count_b = b.1.first().map(|c| c.file_change_count).unwrap_or(0);
612 count_b.cmp(&count_a)
613 });
614 result
615 }
616}
617
618pub fn calculate_change_coupling(
629 events: &[&GitEvent],
630 get_files: impl Fn(&str) -> Option<Vec<String>>,
631 min_commits: usize,
632 min_coupling: f64,
633) -> ChangeCouplingAnalysis {
634 use std::collections::{HashMap, HashSet};
635
636 let mut file_change_counts: HashMap<String, usize> = HashMap::new();
638
639 let mut commit_files: Vec<HashSet<String>> = Vec::new();
641
642 for event in events {
643 if let Some(files) = get_files(&event.short_hash) {
644 let file_set: HashSet<String> = files.iter().cloned().collect();
645 for file in &file_set {
646 *file_change_counts.entry(file.clone()).or_insert(0) += 1;
647 }
648 commit_files.push(file_set);
649 }
650 }
651
652 let mut pair_counts: HashMap<(String, String), usize> = HashMap::new();
654
655 for files in &commit_files {
656 let files_vec: Vec<_> = files.iter().collect();
657 for i in 0..files_vec.len() {
658 for j in (i + 1)..files_vec.len() {
659 let file_a = files_vec[i].clone();
660 let file_b = files_vec[j].clone();
661 *pair_counts
663 .entry((file_a.clone(), file_b.clone()))
664 .or_insert(0) += 1;
665 *pair_counts.entry((file_b, file_a)).or_insert(0) += 1;
666 }
667 }
668 }
669
670 let mut couplings: Vec<FileCoupling> = Vec::new();
672 let mut high_coupling_count = 0;
673 let mut analyzed_files: HashSet<String> = HashSet::new();
674
675 for ((file, coupled_file), co_change_count) in &pair_counts {
676 let file_change_count = *file_change_counts.get(file).unwrap_or(&0);
677
678 if file_change_count < min_commits {
680 continue;
681 }
682
683 let coupling_percent = *co_change_count as f64 / file_change_count as f64;
684
685 if coupling_percent < min_coupling {
687 continue;
688 }
689
690 analyzed_files.insert(file.clone());
691
692 if coupling_percent >= 0.7 {
693 high_coupling_count += 1;
694 }
695
696 couplings.push(FileCoupling {
697 file: file.clone(),
698 coupled_file: coupled_file.clone(),
699 co_change_count: *co_change_count,
700 file_change_count,
701 coupling_percent,
702 });
703 }
704
705 couplings.sort_by(|a, b| {
707 b.coupling_percent
708 .partial_cmp(&a.coupling_percent)
709 .unwrap_or(std::cmp::Ordering::Equal)
710 });
711
712 high_coupling_count /= 2;
714
715 ChangeCouplingAnalysis {
716 couplings,
717 high_coupling_count,
718 total_files_analyzed: analyzed_files.len(),
719 }
720}
721
722#[derive(Debug, Clone)]
724pub struct AuthorStats {
725 pub name: String,
727 pub commit_count: usize,
729 pub insertions: usize,
731 pub deletions: usize,
733 pub last_commit: DateTime<Local>,
735}
736
737impl AuthorStats {
738 pub fn commit_percentage(&self, total: usize) -> f64 {
740 if total == 0 {
741 0.0
742 } else {
743 (self.commit_count as f64 / total as f64) * 100.0
744 }
745 }
746}
747
748#[derive(Debug, Clone, Default)]
750pub struct RepoStats {
751 pub authors: Vec<AuthorStats>,
753 pub total_commits: usize,
755 pub total_insertions: usize,
757 pub total_deletions: usize,
759}
760
761impl RepoStats {
762 pub fn author_count(&self) -> usize {
764 self.authors.len()
765 }
766}
767
768#[derive(Debug, Clone)]
770pub struct FileHeatmapEntry {
771 pub path: String,
773 pub change_count: usize,
775 pub max_changes: usize,
777}
778
779impl FileHeatmapEntry {
780 pub fn heat_level(&self) -> f64 {
782 if self.max_changes == 0 {
783 0.0
784 } else {
785 self.change_count as f64 / self.max_changes as f64
786 }
787 }
788
789 pub fn heat_bar(&self) -> &'static str {
791 let level = self.heat_level();
792 if level >= 0.8 {
793 "█████"
794 } else if level >= 0.6 {
795 "████ "
796 } else if level >= 0.4 {
797 "███ "
798 } else if level >= 0.2 {
799 "██ "
800 } else {
801 "█ "
802 }
803 }
804}
805
806#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
808pub enum AggregationLevel {
809 #[default]
811 Files,
812 Shallow,
814 Deep,
816}
817
818impl AggregationLevel {
819 pub fn next(&self) -> Self {
821 match self {
822 AggregationLevel::Files => AggregationLevel::Shallow,
823 AggregationLevel::Shallow => AggregationLevel::Deep,
824 AggregationLevel::Deep => AggregationLevel::Files,
825 }
826 }
827
828 pub fn prev(&self) -> Self {
830 match self {
831 AggregationLevel::Files => AggregationLevel::Deep,
832 AggregationLevel::Shallow => AggregationLevel::Files,
833 AggregationLevel::Deep => AggregationLevel::Shallow,
834 }
835 }
836
837 pub fn display_name(&self) -> &'static str {
839 match self {
840 AggregationLevel::Files => "Files",
841 AggregationLevel::Shallow => "Directories (2 levels)",
842 AggregationLevel::Deep => "Directories (top level)",
843 }
844 }
845}
846
847#[derive(Debug, Clone, Default)]
849pub struct FileHeatmap {
850 pub files: Vec<FileHeatmapEntry>,
852 pub total_files: usize,
854 pub aggregation_level: AggregationLevel,
856}
857
858impl FileHeatmap {
859 pub fn file_count(&self) -> usize {
861 self.files.len()
862 }
863
864 pub fn with_aggregation(&self, level: AggregationLevel) -> FileHeatmap {
866 if level == AggregationLevel::Files {
867 return FileHeatmap {
869 files: self.files.clone(),
870 total_files: self.total_files,
871 aggregation_level: level,
872 };
873 }
874
875 let mut dir_counts: HashMap<String, usize> = HashMap::new();
877
878 for entry in &self.files {
879 let dir = extract_directory(&entry.path, level);
880 *dir_counts.entry(dir).or_insert(0) += entry.change_count;
881 }
882
883 let max_changes = dir_counts.values().copied().max().unwrap_or(0);
884
885 let mut files: Vec<FileHeatmapEntry> = dir_counts
886 .into_iter()
887 .map(|(path, change_count)| FileHeatmapEntry {
888 path,
889 change_count,
890 max_changes,
891 })
892 .collect();
893
894 files.sort_by(|a, b| b.change_count.cmp(&a.change_count));
896
897 FileHeatmap {
898 total_files: self.total_files,
899 files,
900 aggregation_level: level,
901 }
902 }
903}
904
905fn extract_directory(path: &str, level: AggregationLevel) -> String {
907 let parts: Vec<&str> = path.split('/').collect();
908 match level {
909 AggregationLevel::Files => path.to_string(),
910 AggregationLevel::Shallow => {
911 if parts.len() > 2 {
913 format!("{}/{}/", parts[0], parts[1])
914 } else if parts.len() == 2 {
915 format!("{}/", parts[0])
916 } else {
917 path.to_string()
918 }
919 }
920 AggregationLevel::Deep => {
921 if parts.len() > 1 {
923 format!("{}/", parts[0])
924 } else {
925 path.to_string()
926 }
927 }
928 }
929}
930
931pub fn calculate_file_heatmap(
933 events: &[&GitEvent],
934 get_files: impl Fn(&str) -> Option<Vec<String>>,
935) -> FileHeatmap {
936 let mut file_counts: HashMap<String, usize> = HashMap::new();
937
938 for event in events {
939 if let Some(files) = get_files(&event.short_hash) {
940 for file in files {
941 *file_counts.entry(file).or_insert(0) += 1;
942 }
943 }
944 }
945
946 let max_changes = file_counts.values().copied().max().unwrap_or(0);
947
948 let mut files: Vec<FileHeatmapEntry> = file_counts
949 .into_iter()
950 .map(|(path, change_count)| FileHeatmapEntry {
951 path,
952 change_count,
953 max_changes,
954 })
955 .collect();
956
957 files.sort_by(|a, b| b.change_count.cmp(&a.change_count));
959
960 let total_files = files.len();
961 FileHeatmap {
962 files,
963 total_files,
964 aggregation_level: AggregationLevel::Files,
965 }
966}
967
968#[derive(Debug, Clone, Default)]
970pub struct ActivityTimeline {
971 pub grid: [[usize; 24]; 7],
975 pub total_commits: usize,
977 pub peak_day: usize,
979 pub peak_hour: usize,
981 pub peak_count: usize,
983 pub max_count: usize,
985}
986
987impl ActivityTimeline {
988 pub fn day_name(day: usize) -> &'static str {
990 match day {
991 0 => "Mon",
992 1 => "Tue",
993 2 => "Wed",
994 3 => "Thu",
995 4 => "Fri",
996 5 => "Sat",
997 6 => "Sun",
998 _ => "???",
999 }
1000 }
1001
1002 pub fn heat_level(&self, day: usize, hour: usize) -> f64 {
1004 if self.max_count == 0 {
1005 0.0
1006 } else {
1007 self.grid[day][hour] as f64 / self.max_count as f64
1008 }
1009 }
1010
1011 pub fn heat_char(level: f64) -> &'static str {
1013 if level >= 0.8 {
1014 "██"
1015 } else if level >= 0.6 {
1016 "▓▓"
1017 } else if level >= 0.4 {
1018 "▒▒"
1019 } else if level >= 0.2 {
1020 "░░"
1021 } else if level > 0.0 {
1022 "··"
1023 } else {
1024 " "
1025 }
1026 }
1027
1028 pub fn peak_summary(&self) -> String {
1030 if self.peak_count == 0 {
1031 "No activity".to_string()
1032 } else {
1033 format!(
1034 "{} {:02}:00-{:02}:00 ({} commits)",
1035 Self::day_name(self.peak_day),
1036 self.peak_hour,
1037 (self.peak_hour + 1) % 24,
1038 self.peak_count
1039 )
1040 }
1041 }
1042}
1043
1044pub fn calculate_activity_timeline(events: &[&GitEvent]) -> ActivityTimeline {
1046 use chrono::Datelike;
1047 use chrono::Timelike;
1048
1049 let mut timeline = ActivityTimeline {
1050 total_commits: events.len(),
1051 ..Default::default()
1052 };
1053
1054 for event in events {
1055 let day = event.timestamp.weekday().num_days_from_monday() as usize;
1057 let hour = event.timestamp.hour() as usize;
1058
1059 timeline.grid[day][hour] += 1;
1060 }
1061
1062 let mut max_count = 0usize;
1064 for (day, hours) in timeline.grid.iter().enumerate() {
1065 for (hour, &count) in hours.iter().enumerate() {
1066 if count > max_count {
1067 max_count = count;
1068 timeline.peak_day = day;
1069 timeline.peak_hour = hour;
1070 timeline.peak_count = count;
1071 }
1072 }
1073 }
1074 timeline.max_count = max_count;
1075
1076 timeline
1077}
1078
1079#[derive(Debug, Clone)]
1081pub struct CodeOwnershipEntry {
1082 pub path: String,
1084 pub primary_author: String,
1086 pub primary_commits: usize,
1088 pub total_commits: usize,
1090 pub depth: usize,
1092 pub is_directory: bool,
1094}
1095
1096impl CodeOwnershipEntry {
1097 pub fn ownership_percentage(&self) -> f64 {
1099 if self.total_commits == 0 {
1100 0.0
1101 } else {
1102 (self.primary_commits as f64 / self.total_commits as f64) * 100.0
1103 }
1104 }
1105}
1106
1107#[derive(Debug, Clone, Default)]
1109pub struct CodeOwnership {
1110 pub entries: Vec<CodeOwnershipEntry>,
1112 pub total_files: usize,
1114}
1115
1116impl CodeOwnership {
1117 pub fn entry_count(&self) -> usize {
1119 self.entries.len()
1120 }
1121}
1122
1123pub fn calculate_ownership(
1125 events: &[&GitEvent],
1126 get_files: impl Fn(&str) -> Option<Vec<String>>,
1127) -> CodeOwnership {
1128 let mut file_author_counts: HashMap<String, HashMap<String, usize>> = HashMap::new();
1130
1131 for event in events {
1132 if let Some(files) = get_files(&event.short_hash) {
1133 for file in files {
1134 let author_counts = file_author_counts.entry(file).or_default();
1135 *author_counts.entry(event.author.clone()).or_insert(0) += 1;
1136 }
1137 }
1138 }
1139
1140 let mut dir_author_counts: HashMap<String, HashMap<String, usize>> = HashMap::new();
1142
1143 for (file_path, author_counts) in &file_author_counts {
1144 let parts: Vec<&str> = file_path.split('/').collect();
1146 for i in 1..parts.len() {
1147 let dir_path = parts[..i].join("/");
1148 let dir_counts = dir_author_counts.entry(dir_path).or_default();
1149 for (author, count) in author_counts {
1150 *dir_counts.entry(author.clone()).or_insert(0) += count;
1151 }
1152 }
1153 }
1154
1155 let mut entries = Vec::new();
1157
1158 let mut dir_paths: Vec<String> = dir_author_counts.keys().cloned().collect();
1160 dir_paths.sort();
1161
1162 for dir_path in dir_paths {
1163 let author_counts = &dir_author_counts[&dir_path];
1164 let (primary_author, primary_commits) = author_counts
1165 .iter()
1166 .max_by_key(|(_, c)| *c)
1167 .map(|(a, c)| (a.clone(), *c))
1168 .unwrap_or_default();
1169 let total_commits: usize = author_counts.values().sum();
1170 let depth = dir_path.matches('/').count();
1171
1172 entries.push(CodeOwnershipEntry {
1173 path: dir_path,
1174 primary_author,
1175 primary_commits,
1176 total_commits,
1177 depth,
1178 is_directory: true,
1179 });
1180 }
1181
1182 let mut file_paths: Vec<String> = file_author_counts.keys().cloned().collect();
1184 file_paths.sort();
1185
1186 for file_path in file_paths {
1187 let author_counts = &file_author_counts[&file_path];
1188 let (primary_author, primary_commits) = author_counts
1189 .iter()
1190 .max_by_key(|(_, c)| *c)
1191 .map(|(a, c)| (a.clone(), *c))
1192 .unwrap_or_default();
1193 let total_commits: usize = author_counts.values().sum();
1194 let depth = file_path.matches('/').count();
1195
1196 entries.push(CodeOwnershipEntry {
1197 path: file_path,
1198 primary_author,
1199 primary_commits,
1200 total_commits,
1201 depth,
1202 is_directory: false,
1203 });
1204 }
1205
1206 entries.sort_by(|a, b| a.path.cmp(&b.path));
1208
1209 let total_files = file_author_counts.len();
1210 CodeOwnership {
1211 entries,
1212 total_files,
1213 }
1214}
1215
1216pub fn calculate_stats(events: &[&GitEvent]) -> RepoStats {
1218 let mut author_map: HashMap<String, AuthorStats> = HashMap::new();
1219 let mut total_insertions = 0usize;
1220 let mut total_deletions = 0usize;
1221
1222 for event in events {
1223 total_insertions += event.files_added;
1224 total_deletions += event.files_deleted;
1225
1226 let entry = author_map
1227 .entry(event.author.clone())
1228 .or_insert(AuthorStats {
1229 name: event.author.clone(),
1230 commit_count: 0,
1231 insertions: 0,
1232 deletions: 0,
1233 last_commit: event.timestamp,
1234 });
1235
1236 entry.commit_count += 1;
1237 entry.insertions += event.files_added;
1238 entry.deletions += event.files_deleted;
1239
1240 if event.timestamp > entry.last_commit {
1242 entry.last_commit = event.timestamp;
1243 }
1244 }
1245
1246 let mut authors: Vec<AuthorStats> = author_map.into_values().collect();
1248 authors.sort_by(|a, b| b.commit_count.cmp(&a.commit_count));
1249
1250 RepoStats {
1251 authors,
1252 total_commits: events.len(),
1253 total_insertions,
1254 total_deletions,
1255 }
1256}
1257
1258#[derive(Debug, Clone)]
1264pub struct BusFactorEntry {
1265 pub path: String,
1267 pub bus_factor: usize,
1269 pub contributors: Vec<ContributorInfo>,
1271 pub total_commits: usize,
1273 pub risk_level: BusFactorRisk,
1275 pub is_directory: bool,
1277}
1278
1279#[derive(Debug, Clone)]
1281pub struct ContributorInfo {
1282 pub name: String,
1284 pub commit_count: usize,
1286 pub contribution_percent: f64,
1288}
1289
1290#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1292pub enum BusFactorRisk {
1293 High,
1295 Medium,
1297 Low,
1299}
1300
1301impl BusFactorRisk {
1302 pub fn display_name(&self) -> &'static str {
1304 match self {
1305 BusFactorRisk::High => "High Risk",
1306 BusFactorRisk::Medium => "Medium Risk",
1307 BusFactorRisk::Low => "Low Risk",
1308 }
1309 }
1310
1311 pub fn color(&self) -> &'static str {
1313 match self {
1314 BusFactorRisk::High => "red",
1315 BusFactorRisk::Medium => "yellow",
1316 BusFactorRisk::Low => "green",
1317 }
1318 }
1319}
1320
1321#[derive(Debug, Clone, Default)]
1323pub struct BusFactorAnalysis {
1324 pub entries: Vec<BusFactorEntry>,
1326 pub high_risk_count: usize,
1328 pub medium_risk_count: usize,
1330 pub total_paths_analyzed: usize,
1332}
1333
1334impl BusFactorAnalysis {
1335 pub fn entry_count(&self) -> usize {
1337 self.entries.len()
1338 }
1339}
1340
1341pub fn calculate_bus_factor(
1350 events: &[&GitEvent],
1351 get_files: impl Fn(&str) -> Option<Vec<String>>,
1352 min_commits: usize,
1353) -> BusFactorAnalysis {
1354 let mut dir_author_counts: HashMap<String, HashMap<String, usize>> = HashMap::new();
1356
1357 for event in events {
1358 if let Some(files) = get_files(&event.short_hash) {
1359 for file in &files {
1360 let parts: Vec<&str> = file.split('/').collect();
1362 if parts.len() > 1 {
1363 let top_dir = parts[0].to_string();
1364 let counts = dir_author_counts.entry(top_dir).or_default();
1365 *counts.entry(event.author.clone()).or_insert(0) += 1;
1366 }
1367 if parts.len() > 2 {
1369 let two_level_dir = format!("{}/{}", parts[0], parts[1]);
1370 let counts = dir_author_counts.entry(two_level_dir).or_default();
1371 *counts.entry(event.author.clone()).or_insert(0) += 1;
1372 }
1373 }
1374 }
1375 }
1376
1377 let mut entries = Vec::new();
1378 let mut high_risk_count = 0;
1379 let mut medium_risk_count = 0;
1380
1381 for (path, author_counts) in &dir_author_counts {
1382 let total_commits: usize = author_counts.values().sum();
1383
1384 if total_commits < min_commits {
1386 continue;
1387 }
1388
1389 let mut contributors: Vec<ContributorInfo> = author_counts
1391 .iter()
1392 .map(|(name, &count)| ContributorInfo {
1393 name: name.clone(),
1394 commit_count: count,
1395 contribution_percent: (count as f64 / total_commits as f64) * 100.0,
1396 })
1397 .collect();
1398 contributors.sort_by(|a, b| b.commit_count.cmp(&a.commit_count));
1399
1400 let mut cumulative = 0.0;
1402 let mut bus_factor = 0;
1403 for contributor in &contributors {
1404 cumulative += contributor.contribution_percent;
1405 bus_factor += 1;
1406 if cumulative >= 50.0 {
1407 break;
1408 }
1409 }
1410
1411 let risk_level = match bus_factor {
1412 1 => {
1413 high_risk_count += 1;
1414 BusFactorRisk::High
1415 }
1416 2 => {
1417 medium_risk_count += 1;
1418 BusFactorRisk::Medium
1419 }
1420 _ => BusFactorRisk::Low,
1421 };
1422
1423 entries.push(BusFactorEntry {
1424 path: path.clone(),
1425 bus_factor,
1426 contributors: contributors.into_iter().take(5).collect(), total_commits,
1428 risk_level,
1429 is_directory: true,
1430 });
1431 }
1432
1433 entries.sort_by(|a, b| match (&a.risk_level, &b.risk_level) {
1435 (BusFactorRisk::High, BusFactorRisk::High)
1436 | (BusFactorRisk::Medium, BusFactorRisk::Medium)
1437 | (BusFactorRisk::Low, BusFactorRisk::Low) => b.total_commits.cmp(&a.total_commits),
1438 (BusFactorRisk::High, _) => std::cmp::Ordering::Less,
1439 (_, BusFactorRisk::High) => std::cmp::Ordering::Greater,
1440 (BusFactorRisk::Medium, BusFactorRisk::Low) => std::cmp::Ordering::Less,
1441 (BusFactorRisk::Low, BusFactorRisk::Medium) => std::cmp::Ordering::Greater,
1442 });
1443
1444 BusFactorAnalysis {
1445 total_paths_analyzed: entries.len(),
1446 entries,
1447 high_risk_count,
1448 medium_risk_count,
1449 }
1450}
1451
1452#[derive(Debug, Clone)]
1458pub struct TechDebtEntry {
1459 pub path: String,
1461 pub score: f64,
1463 pub churn_score: f64,
1465 pub complexity_score: f64,
1467 pub age_score: f64,
1469 pub change_count: usize,
1471 pub total_changes: usize,
1473 pub debt_level: TechDebtLevel,
1475}
1476
1477#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1479pub enum TechDebtLevel {
1480 High,
1482 Medium,
1484 Low,
1486}
1487
1488impl TechDebtLevel {
1489 pub fn display_name(&self) -> &'static str {
1491 match self {
1492 TechDebtLevel::High => "High Debt",
1493 TechDebtLevel::Medium => "Medium Debt",
1494 TechDebtLevel::Low => "Low Debt",
1495 }
1496 }
1497
1498 pub fn color(&self) -> &'static str {
1500 match self {
1501 TechDebtLevel::High => "red",
1502 TechDebtLevel::Medium => "yellow",
1503 TechDebtLevel::Low => "green",
1504 }
1505 }
1506}
1507
1508#[derive(Debug, Clone, Default)]
1510pub struct TechDebtAnalysis {
1511 pub entries: Vec<TechDebtEntry>,
1513 pub avg_score: f64,
1515 pub high_debt_count: usize,
1517 pub total_files_analyzed: usize,
1519}
1520
1521impl TechDebtAnalysis {
1522 pub fn entry_count(&self) -> usize {
1524 self.entries.len()
1525 }
1526}
1527
1528pub fn calculate_tech_debt(
1537 events: &[&GitEvent],
1538 get_files: impl Fn(&str) -> Option<Vec<String>>,
1539 min_commits: usize,
1540) -> TechDebtAnalysis {
1541 use chrono::Local;
1542
1543 let mut file_stats: HashMap<String, (usize, usize, DateTime<Local>)> = HashMap::new();
1545
1546 for event in events {
1547 if let Some(files) = get_files(&event.short_hash) {
1548 let changes_per_file = (event.files_added + event.files_deleted) / files.len().max(1);
1549 for file in files {
1550 let entry = file_stats.entry(file).or_insert((0, 0, event.timestamp));
1551 entry.0 += 1; entry.1 += changes_per_file; if event.timestamp > entry.2 {
1555 entry.2 = event.timestamp;
1556 }
1557 }
1558 }
1559 }
1560
1561 let max_changes = file_stats.values().map(|(c, _, _)| *c).max().unwrap_or(1);
1563 let max_lines = file_stats.values().map(|(_, l, _)| *l).max().unwrap_or(1);
1564 let now = Local::now();
1565
1566 let mut entries = Vec::new();
1567 let mut total_score = 0.0;
1568 let mut high_debt_count = 0;
1569
1570 for (path, (change_count, total_changes, last_modified)) in &file_stats {
1571 if *change_count < min_commits {
1572 continue;
1573 }
1574
1575 let churn_score = *change_count as f64 / max_changes as f64;
1577 let complexity_score = *total_changes as f64 / max_lines as f64;
1578
1579 let days_since_change = (now - *last_modified).num_days().max(0) as f64;
1581 let age_score = (days_since_change / 365.0).min(1.0);
1582
1583 let score = churn_score * 0.5 + complexity_score * 0.4 + (churn_score * age_score) * 0.1;
1587
1588 let debt_level = if score >= 0.6 {
1589 high_debt_count += 1;
1590 TechDebtLevel::High
1591 } else if score >= 0.3 {
1592 TechDebtLevel::Medium
1593 } else {
1594 TechDebtLevel::Low
1595 };
1596
1597 entries.push(TechDebtEntry {
1598 path: path.clone(),
1599 score,
1600 churn_score,
1601 complexity_score,
1602 age_score,
1603 change_count: *change_count,
1604 total_changes: *total_changes,
1605 debt_level,
1606 });
1607
1608 total_score += score;
1609 }
1610
1611 entries.sort_by(|a, b| {
1613 b.score
1614 .partial_cmp(&a.score)
1615 .unwrap_or(std::cmp::Ordering::Equal)
1616 });
1617
1618 let total_files_analyzed = entries.len();
1619 let avg_score = if total_files_analyzed > 0 {
1620 total_score / total_files_analyzed as f64
1621 } else {
1622 0.0
1623 };
1624
1625 TechDebtAnalysis {
1626 entries,
1627 avg_score,
1628 high_debt_count,
1629 total_files_analyzed,
1630 }
1631}
1632
1633#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
1639pub enum AlertSeverity {
1640 Info,
1642 Warning,
1644 Critical,
1646}
1647
1648impl AlertSeverity {
1649 pub fn color(&self) -> &'static str {
1651 match self {
1652 AlertSeverity::Info => "blue",
1653 AlertSeverity::Warning => "yellow",
1654 AlertSeverity::Critical => "red",
1655 }
1656 }
1657
1658 pub fn icon(&self) -> &'static str {
1660 match self {
1661 AlertSeverity::Info => "ℹ",
1662 AlertSeverity::Warning => "⚠",
1663 AlertSeverity::Critical => "🔴",
1664 }
1665 }
1666}
1667
1668#[derive(Debug, Clone)]
1670pub struct HealthAlert {
1671 pub severity: AlertSeverity,
1673 pub message: String,
1675 pub details: Option<String>,
1677}
1678
1679impl HealthAlert {
1680 pub fn new(severity: AlertSeverity, message: impl Into<String>) -> Self {
1682 Self {
1683 severity,
1684 message: message.into(),
1685 details: None,
1686 }
1687 }
1688
1689 pub fn with_details(
1691 severity: AlertSeverity,
1692 message: impl Into<String>,
1693 details: impl Into<String>,
1694 ) -> Self {
1695 Self {
1696 severity,
1697 message: message.into(),
1698 details: Some(details.into()),
1699 }
1700 }
1701}
1702
1703#[derive(Debug, Clone, Default)]
1705pub struct HealthScoreComponent {
1706 pub score: f64,
1708 pub weight: f64,
1710 pub description: String,
1712}
1713
1714#[derive(Debug, Clone)]
1716pub struct ProjectHealth {
1717 pub overall_score: u8,
1719 pub quality: HealthScoreComponent,
1721 pub test_health: HealthScoreComponent,
1723 pub bus_factor_risk: HealthScoreComponent,
1725 pub tech_debt: HealthScoreComponent,
1727 pub alerts: Vec<HealthAlert>,
1729 pub total_commits: usize,
1731 pub total_authors: usize,
1733 pub analysis_period_days: u64,
1735}
1736
1737impl Default for ProjectHealth {
1738 fn default() -> Self {
1739 Self {
1740 overall_score: 50,
1741 quality: HealthScoreComponent {
1742 score: 0.5,
1743 weight: 0.25,
1744 description: "Commit quality average".to_string(),
1745 },
1746 test_health: HealthScoreComponent {
1747 score: 0.5,
1748 weight: 0.25,
1749 description: "Test commit ratio".to_string(),
1750 },
1751 bus_factor_risk: HealthScoreComponent {
1752 score: 0.5,
1753 weight: 0.25,
1754 description: "Knowledge concentration (lower is better)".to_string(),
1755 },
1756 tech_debt: HealthScoreComponent {
1757 score: 0.5,
1758 weight: 0.25,
1759 description: "Technical debt (lower is better)".to_string(),
1760 },
1761 alerts: Vec::new(),
1762 total_commits: 0,
1763 total_authors: 0,
1764 analysis_period_days: 0,
1765 }
1766 }
1767}
1768
1769impl ProjectHealth {
1770 pub fn level(&self) -> &'static str {
1772 if self.overall_score >= 80 {
1773 "Excellent"
1774 } else if self.overall_score >= 60 {
1775 "Good"
1776 } else if self.overall_score >= 40 {
1777 "Fair"
1778 } else {
1779 "Poor"
1780 }
1781 }
1782
1783 pub fn score_color(&self) -> &'static str {
1785 if self.overall_score >= 80 {
1786 "green"
1787 } else if self.overall_score >= 60 {
1788 "cyan"
1789 } else if self.overall_score >= 40 {
1790 "yellow"
1791 } else {
1792 "red"
1793 }
1794 }
1795
1796 pub fn score_bar(&self) -> String {
1798 let filled = (self.overall_score / 10) as usize;
1799 let empty = 10 - filled;
1800 format!("{}{}", "█".repeat(filled), "░".repeat(empty))
1801 }
1802}
1803
1804pub fn calculate_project_health<F>(
1813 events: &[&GitEvent],
1814 files_fn: F,
1815 quality_analysis: Option<&CommitQualityAnalysis>,
1816 bus_factor: Option<&BusFactorAnalysis>,
1817 tech_debt_analysis: Option<&TechDebtAnalysis>,
1818) -> ProjectHealth
1819where
1820 F: Fn(&str) -> Option<Vec<String>>,
1821{
1822 if events.is_empty() {
1823 return ProjectHealth::default();
1824 }
1825
1826 let total_commits = events.len();
1827 let mut alerts = Vec::new();
1828
1829 let authors: std::collections::HashSet<&str> =
1831 events.iter().map(|e| e.author.as_str()).collect();
1832 let total_authors = authors.len();
1833
1834 let analysis_period_days = if events.len() >= 2 {
1836 let newest = events.first().map(|e| e.timestamp);
1837 let oldest = events.last().map(|e| e.timestamp);
1838 if let (Some(n), Some(o)) = (newest, oldest) {
1839 let duration = n.signed_duration_since(o);
1840 duration.num_days().unsigned_abs()
1841 } else {
1842 0
1843 }
1844 } else {
1845 0
1846 };
1847
1848 let quality_score = if let Some(qa) = quality_analysis {
1850 qa.avg_score
1851 } else {
1852 let conventional_count = events
1854 .iter()
1855 .filter(|e| {
1856 let msg = e.message.to_lowercase();
1857 msg.starts_with("feat:")
1858 || msg.starts_with("fix:")
1859 || msg.starts_with("docs:")
1860 || msg.starts_with("style:")
1861 || msg.starts_with("refactor:")
1862 || msg.starts_with("test:")
1863 || msg.starts_with("chore:")
1864 || msg.starts_with("perf:")
1865 })
1866 .count();
1867 conventional_count as f64 / total_commits as f64
1868 };
1869
1870 let quality = HealthScoreComponent {
1871 score: quality_score,
1872 weight: 0.25,
1873 description: format!(
1874 "Commit quality: {:.0}% conventional commits",
1875 quality_score * 100.0
1876 ),
1877 };
1878
1879 if quality_score < 0.3 {
1880 alerts.push(HealthAlert::with_details(
1881 AlertSeverity::Warning,
1882 "Low commit quality",
1883 "Consider using conventional commit messages (feat:, fix:, etc.)",
1884 ));
1885 }
1886
1887 let test_commit_count = events
1889 .iter()
1890 .filter(|e| {
1891 let msg = e.message.to_lowercase();
1892 msg.starts_with("test:") || msg.contains("test") || msg.contains("spec")
1893 })
1894 .count();
1895 let test_file_commit_count = events
1896 .iter()
1897 .filter(|e| {
1898 if let Some(files) = files_fn(&e.short_hash) {
1899 files.iter().any(|f| {
1900 f.contains("test")
1901 || f.contains("spec")
1902 || f.ends_with("_test.rs")
1903 || f.ends_with(".test.")
1904 })
1905 } else {
1906 false
1907 }
1908 })
1909 .count();
1910 let test_ratio =
1911 (test_commit_count + test_file_commit_count) as f64 / (total_commits * 2) as f64;
1912 let test_score = test_ratio.min(1.0);
1913
1914 let test_health = HealthScoreComponent {
1915 score: test_score,
1916 weight: 0.25,
1917 description: format!(
1918 "Test coverage: {:.0}% test-related commits",
1919 test_score * 100.0
1920 ),
1921 };
1922
1923 if test_score < 0.1 {
1924 alerts.push(HealthAlert::with_details(
1925 AlertSeverity::Info,
1926 "Low test coverage in commits",
1927 "Consider adding more test commits or test-related changes",
1928 ));
1929 }
1930
1931 let bus_factor_score = if let Some(bf) = bus_factor {
1933 let low_risk_count = bf
1935 .total_paths_analyzed
1936 .saturating_sub(bf.high_risk_count + bf.medium_risk_count);
1937 let risk_ratio = bf.high_risk_count as f64
1938 / (bf.high_risk_count + bf.medium_risk_count + low_risk_count).max(1) as f64;
1939 1.0 - risk_ratio
1940 } else {
1941 let mut author_commits: HashMap<&str, usize> = HashMap::new();
1943 for e in events.iter() {
1944 *author_commits.entry(e.author.as_str()).or_insert(0) += 1;
1945 }
1946 let max_commits = author_commits.values().max().copied().unwrap_or(0);
1948 let concentration = max_commits as f64 / total_commits as f64;
1949 1.0 - concentration
1950 };
1951
1952 let bus_factor_risk = HealthScoreComponent {
1953 score: bus_factor_score,
1954 weight: 0.25,
1955 description: format!(
1956 "Knowledge distribution: {:.0}% (higher is better)",
1957 bus_factor_score * 100.0
1958 ),
1959 };
1960
1961 if bus_factor_score < 0.3 {
1962 alerts.push(HealthAlert::with_details(
1963 AlertSeverity::Critical,
1964 "High bus factor risk",
1965 "Knowledge is concentrated in few contributors. Consider knowledge sharing.",
1966 ));
1967 } else if bus_factor_score < 0.5 {
1968 alerts.push(HealthAlert::with_details(
1969 AlertSeverity::Warning,
1970 "Moderate bus factor risk",
1971 "Consider improving knowledge distribution across team members.",
1972 ));
1973 }
1974
1975 let tech_debt_score = if let Some(td) = tech_debt_analysis {
1977 1.0 - td.avg_score.min(1.0)
1978 } else {
1979 let large_commits = events
1981 .iter()
1982 .filter(|e| e.files_added + e.files_deleted > 50)
1983 .count();
1984 let large_ratio = large_commits as f64 / total_commits as f64;
1985 1.0 - large_ratio.min(1.0)
1986 };
1987
1988 let tech_debt = HealthScoreComponent {
1989 score: tech_debt_score,
1990 weight: 0.25,
1991 description: format!(
1992 "Technical debt: {:.0}% clean (higher is better)",
1993 tech_debt_score * 100.0
1994 ),
1995 };
1996
1997 if tech_debt_score < 0.3 {
1998 alerts.push(HealthAlert::with_details(
1999 AlertSeverity::Warning,
2000 "High technical debt indicated",
2001 "Many large commits suggest accumulated technical debt.",
2002 ));
2003 }
2004
2005 let overall = quality.score * quality.weight
2007 + test_health.score * test_health.weight
2008 + bus_factor_risk.score * bus_factor_risk.weight
2009 + tech_debt.score * tech_debt.weight;
2010 let overall_score = (overall * 100.0).round() as u8;
2011
2012 alerts.sort_by(|a, b| b.severity.cmp(&a.severity));
2014
2015 ProjectHealth {
2016 overall_score,
2017 quality,
2018 test_health,
2019 bus_factor_risk,
2020 tech_debt,
2021 alerts,
2022 total_commits,
2023 total_authors,
2024 analysis_period_days,
2025 }
2026}
2027
2028#[cfg(test)]
2029#[allow(clippy::useless_vec)]
2030mod tests {
2031 use super::*;
2032 use chrono::Local;
2033
2034 fn create_test_event(author: &str, insertions: usize, deletions: usize) -> GitEvent {
2035 GitEvent::commit(
2036 "abc1234".to_string(),
2037 "test commit".to_string(),
2038 author.to_string(),
2039 Local::now(),
2040 insertions,
2041 deletions,
2042 )
2043 }
2044
2045 #[test]
2046 fn test_calculate_stats_empty() {
2047 let stats = calculate_stats(&[]);
2048 assert_eq!(stats.total_commits, 0);
2049 assert_eq!(stats.authors.len(), 0);
2050 }
2051
2052 #[test]
2053 fn test_calculate_stats_single_author() {
2054 let events = vec![
2055 create_test_event("Alice", 10, 5),
2056 create_test_event("Alice", 20, 10),
2057 ];
2058 let refs: Vec<&GitEvent> = events.iter().collect();
2059 let stats = calculate_stats(&refs);
2060
2061 assert_eq!(stats.total_commits, 2);
2062 assert_eq!(stats.authors.len(), 1);
2063 assert_eq!(stats.authors[0].name, "Alice");
2064 assert_eq!(stats.authors[0].commit_count, 2);
2065 assert_eq!(stats.authors[0].insertions, 30);
2066 assert_eq!(stats.authors[0].deletions, 15);
2067 }
2068
2069 #[test]
2070 fn test_calculate_stats_multiple_authors() {
2071 let events = vec![
2072 create_test_event("Alice", 10, 5),
2073 create_test_event("Bob", 5, 2),
2074 create_test_event("Alice", 20, 10),
2075 create_test_event("Bob", 15, 8),
2076 create_test_event("Bob", 10, 5),
2077 ];
2078 let refs: Vec<&GitEvent> = events.iter().collect();
2079 let stats = calculate_stats(&refs);
2080
2081 assert_eq!(stats.total_commits, 5);
2082 assert_eq!(stats.authors.len(), 2);
2083
2084 assert_eq!(stats.authors[0].name, "Bob");
2086 assert_eq!(stats.authors[0].commit_count, 3);
2087
2088 assert_eq!(stats.authors[1].name, "Alice");
2090 assert_eq!(stats.authors[1].commit_count, 2);
2091 }
2092
2093 #[test]
2094 fn test_calculate_stats_totals() {
2095 let events = vec![
2096 create_test_event("Alice", 10, 5),
2097 create_test_event("Bob", 20, 10),
2098 ];
2099 let refs: Vec<&GitEvent> = events.iter().collect();
2100 let stats = calculate_stats(&refs);
2101
2102 assert_eq!(stats.total_insertions, 30);
2103 assert_eq!(stats.total_deletions, 15);
2104 }
2105
2106 #[test]
2107 fn test_author_stats_commit_percentage() {
2108 let author = AuthorStats {
2109 name: "Alice".to_string(),
2110 commit_count: 25,
2111 insertions: 0,
2112 deletions: 0,
2113 last_commit: Local::now(),
2114 };
2115
2116 assert!((author.commit_percentage(100) - 25.0).abs() < 0.01);
2117 assert!((author.commit_percentage(50) - 50.0).abs() < 0.01);
2118 }
2119
2120 #[test]
2121 fn test_author_stats_commit_percentage_zero() {
2122 let author = AuthorStats {
2123 name: "Alice".to_string(),
2124 commit_count: 10,
2125 insertions: 0,
2126 deletions: 0,
2127 last_commit: Local::now(),
2128 };
2129
2130 assert_eq!(author.commit_percentage(0), 0.0);
2131 }
2132
2133 #[test]
2134 fn test_repo_stats_author_count() {
2135 let events = vec![
2136 create_test_event("Alice", 10, 5),
2137 create_test_event("Bob", 5, 2),
2138 create_test_event("Charlie", 15, 8),
2139 ];
2140 let refs: Vec<&GitEvent> = events.iter().collect();
2141 let stats = calculate_stats(&refs);
2142
2143 assert_eq!(stats.author_count(), 3);
2144 }
2145
2146 fn create_test_event_with_hash(hash: &str) -> GitEvent {
2149 GitEvent::commit(
2150 hash.to_string(),
2151 "test commit".to_string(),
2152 "author".to_string(),
2153 Local::now(),
2154 10,
2155 5,
2156 )
2157 }
2158
2159 #[test]
2160 fn test_calculate_file_heatmap_empty() {
2161 let events: Vec<&GitEvent> = vec![];
2162 let heatmap = calculate_file_heatmap(&events, |_| None);
2163 assert_eq!(heatmap.file_count(), 0);
2164 }
2165
2166 #[test]
2167 fn test_calculate_file_heatmap_single_file() {
2168 let events = vec![
2169 create_test_event_with_hash("abc1234"),
2170 create_test_event_with_hash("def5678"),
2171 ];
2172 let refs: Vec<&GitEvent> = events.iter().collect();
2173 let heatmap = calculate_file_heatmap(&refs, |_| Some(vec!["src/main.rs".to_string()]));
2174
2175 assert_eq!(heatmap.file_count(), 1);
2176 assert_eq!(heatmap.files[0].path, "src/main.rs");
2177 assert_eq!(heatmap.files[0].change_count, 2);
2178 }
2179
2180 #[test]
2181 fn test_calculate_file_heatmap_multiple_files() {
2182 let events = vec![
2183 create_test_event_with_hash("abc1234"),
2184 create_test_event_with_hash("def5678"),
2185 create_test_event_with_hash("ghi9012"),
2186 ];
2187 let refs: Vec<&GitEvent> = events.iter().collect();
2188 let heatmap = calculate_file_heatmap(&refs, |hash| match hash {
2189 "abc1234" => Some(vec!["src/a.rs".to_string(), "src/b.rs".to_string()]),
2190 "def5678" => Some(vec!["src/a.rs".to_string()]),
2191 "ghi9012" => Some(vec!["src/a.rs".to_string(), "src/c.rs".to_string()]),
2192 _ => None,
2193 });
2194
2195 assert_eq!(heatmap.file_count(), 3);
2196 assert_eq!(heatmap.files[0].path, "src/a.rs");
2198 assert_eq!(heatmap.files[0].change_count, 3);
2199 }
2200
2201 #[test]
2202 fn test_file_heatmap_entry_heat_level() {
2203 let entry = FileHeatmapEntry {
2204 path: "test.rs".to_string(),
2205 change_count: 5,
2206 max_changes: 10,
2207 };
2208 assert!((entry.heat_level() - 0.5).abs() < 0.01);
2209 }
2210
2211 #[test]
2212 fn test_file_heatmap_entry_heat_bar() {
2213 let entry_high = FileHeatmapEntry {
2214 path: "test.rs".to_string(),
2215 change_count: 10,
2216 max_changes: 10,
2217 };
2218 assert_eq!(entry_high.heat_bar(), "█████");
2219
2220 let entry_low = FileHeatmapEntry {
2221 path: "test.rs".to_string(),
2222 change_count: 1,
2223 max_changes: 10,
2224 };
2225 assert_eq!(entry_low.heat_bar(), "█ ");
2226 }
2227
2228 #[test]
2231 fn test_aggregation_level_next() {
2232 assert_eq!(AggregationLevel::Files.next(), AggregationLevel::Shallow);
2233 assert_eq!(AggregationLevel::Shallow.next(), AggregationLevel::Deep);
2234 assert_eq!(AggregationLevel::Deep.next(), AggregationLevel::Files);
2235 }
2236
2237 #[test]
2238 fn test_aggregation_level_prev() {
2239 assert_eq!(AggregationLevel::Files.prev(), AggregationLevel::Deep);
2240 assert_eq!(AggregationLevel::Shallow.prev(), AggregationLevel::Files);
2241 assert_eq!(AggregationLevel::Deep.prev(), AggregationLevel::Shallow);
2242 }
2243
2244 #[test]
2245 fn test_aggregation_level_display_name() {
2246 assert_eq!(AggregationLevel::Files.display_name(), "Files");
2247 assert!(AggregationLevel::Shallow
2248 .display_name()
2249 .contains("2 levels"));
2250 assert!(AggregationLevel::Deep.display_name().contains("top level"));
2251 }
2252
2253 #[test]
2254 fn test_heatmap_with_aggregation_shallow() {
2255 let heatmap = FileHeatmap {
2256 files: vec![
2257 FileHeatmapEntry {
2258 path: "src/auth/login.rs".to_string(),
2259 change_count: 10,
2260 max_changes: 10,
2261 },
2262 FileHeatmapEntry {
2263 path: "src/auth/token.rs".to_string(),
2264 change_count: 5,
2265 max_changes: 10,
2266 },
2267 FileHeatmapEntry {
2268 path: "src/api/user.rs".to_string(),
2269 change_count: 3,
2270 max_changes: 10,
2271 },
2272 ],
2273 total_files: 3,
2274 aggregation_level: AggregationLevel::Files,
2275 };
2276
2277 let aggregated = heatmap.with_aggregation(AggregationLevel::Shallow);
2278 assert_eq!(aggregated.aggregation_level, AggregationLevel::Shallow);
2279 assert_eq!(aggregated.files.len(), 2); assert_eq!(aggregated.files[0].path, "src/auth/");
2283 assert_eq!(aggregated.files[0].change_count, 15);
2284 }
2285
2286 #[test]
2287 fn test_heatmap_with_aggregation_deep() {
2288 let heatmap = FileHeatmap {
2289 files: vec![
2290 FileHeatmapEntry {
2291 path: "src/auth/login.rs".to_string(),
2292 change_count: 10,
2293 max_changes: 10,
2294 },
2295 FileHeatmapEntry {
2296 path: "src/api/user.rs".to_string(),
2297 change_count: 5,
2298 max_changes: 10,
2299 },
2300 FileHeatmapEntry {
2301 path: "tests/test.rs".to_string(),
2302 change_count: 3,
2303 max_changes: 10,
2304 },
2305 ],
2306 total_files: 3,
2307 aggregation_level: AggregationLevel::Files,
2308 };
2309
2310 let aggregated = heatmap.with_aggregation(AggregationLevel::Deep);
2311 assert_eq!(aggregated.aggregation_level, AggregationLevel::Deep);
2312 assert_eq!(aggregated.files.len(), 2); assert_eq!(aggregated.files[0].path, "src/");
2316 assert_eq!(aggregated.files[0].change_count, 15);
2317 }
2318
2319 fn create_test_event_with_hash_changes(
2322 hash: &str,
2323 author: &str,
2324 insertions: usize,
2325 deletions: usize,
2326 ) -> GitEvent {
2327 GitEvent::commit(
2328 hash.to_string(),
2329 "test commit".to_string(),
2330 author.to_string(),
2331 Local::now(),
2332 insertions,
2333 deletions,
2334 )
2335 }
2336
2337 #[test]
2338 fn test_calculate_impact_scores_empty() {
2339 let events: Vec<&GitEvent> = vec![];
2340 let heatmap = FileHeatmap::default();
2341 let analysis = calculate_impact_scores(&events, |_| None, &heatmap);
2342 assert_eq!(analysis.total_commits, 0);
2343 assert_eq!(analysis.commits.len(), 0);
2344 assert_eq!(analysis.avg_score, 0.0);
2345 }
2346
2347 #[test]
2348 fn test_calculate_impact_scores_single_commit() {
2349 let events = vec![create_test_event_with_hash_changes(
2350 "abc1234", "Alice", 100, 50,
2351 )];
2352 let refs: Vec<&GitEvent> = events.iter().collect();
2353 let heatmap = FileHeatmap::default();
2354
2355 let analysis =
2356 calculate_impact_scores(&refs, |_| Some(vec!["src/main.rs".to_string()]), &heatmap);
2357
2358 assert_eq!(analysis.total_commits, 1);
2359 assert_eq!(analysis.commits.len(), 1);
2360
2361 let commit = &analysis.commits[0];
2362 assert_eq!(commit.commit_hash, "abc1234");
2363 assert_eq!(commit.files_changed, 1);
2364 assert_eq!(commit.insertions, 100);
2365 assert_eq!(commit.deletions, 50);
2366 }
2367
2368 #[test]
2369 fn test_calculate_impact_scores_file_score() {
2370 let events = vec![create_test_event_with_hash_changes(
2372 "abc1234", "Alice", 10, 5,
2373 )];
2374 let refs: Vec<&GitEvent> = events.iter().collect();
2375 let heatmap = FileHeatmap::default();
2376
2377 let files: Vec<String> = (0..50).map(|i| format!("file{}.rs", i)).collect();
2379 let analysis = calculate_impact_scores(&refs, |_| Some(files.clone()), &heatmap);
2380
2381 let commit = &analysis.commits[0];
2382 assert!((commit.file_score - 0.4).abs() < 0.01);
2383 }
2384
2385 #[test]
2386 fn test_calculate_impact_scores_change_score() {
2387 let events = vec![create_test_event_with_hash_changes(
2389 "abc1234", "Alice", 250, 250,
2390 )];
2391 let refs: Vec<&GitEvent> = events.iter().collect();
2392 let heatmap = FileHeatmap::default();
2393
2394 let analysis =
2395 calculate_impact_scores(&refs, |_| Some(vec!["src/main.rs".to_string()]), &heatmap);
2396
2397 let commit = &analysis.commits[0];
2398 assert!((commit.change_score - 0.4).abs() < 0.01);
2399 }
2400
2401 #[test]
2402 fn test_calculate_impact_scores_sorted_by_score_desc() {
2403 let events = vec![
2404 create_test_event_with_hash_changes("low", "Alice", 10, 5), create_test_event_with_hash_changes("high", "Bob", 400, 100), create_test_event_with_hash_changes("medium", "Carol", 100, 50), ];
2408 let refs: Vec<&GitEvent> = events.iter().collect();
2409 let heatmap = FileHeatmap::default();
2410
2411 let analysis =
2412 calculate_impact_scores(&refs, |_| Some(vec!["src/main.rs".to_string()]), &heatmap);
2413
2414 assert_eq!(analysis.commits.len(), 3);
2415 assert_eq!(analysis.commits[0].commit_hash, "high");
2417 assert_eq!(analysis.commits[1].commit_hash, "medium");
2418 assert_eq!(analysis.commits[2].commit_hash, "low");
2419 }
2420
2421 #[test]
2422 fn test_commit_impact_score_color_high() {
2423 let commit = CommitImpactScore {
2424 commit_hash: "abc".to_string(),
2425 commit_message: "test".to_string(),
2426 author: "Alice".to_string(),
2427 date: Local::now(),
2428 files_changed: 0,
2429 insertions: 0,
2430 deletions: 0,
2431 score: 0.8,
2432 file_score: 0.0,
2433 change_score: 0.0,
2434 heat_score: 0.0,
2435 };
2436 assert_eq!(commit.score_color(), "red");
2437 }
2438
2439 #[test]
2440 fn test_commit_impact_score_color_medium() {
2441 let commit = CommitImpactScore {
2442 commit_hash: "abc".to_string(),
2443 commit_message: "test".to_string(),
2444 author: "Alice".to_string(),
2445 date: Local::now(),
2446 files_changed: 0,
2447 insertions: 0,
2448 deletions: 0,
2449 score: 0.5,
2450 file_score: 0.0,
2451 change_score: 0.0,
2452 heat_score: 0.0,
2453 };
2454 assert_eq!(commit.score_color(), "yellow");
2455 }
2456
2457 #[test]
2458 fn test_commit_impact_score_color_low() {
2459 let commit = CommitImpactScore {
2460 commit_hash: "abc".to_string(),
2461 commit_message: "test".to_string(),
2462 author: "Alice".to_string(),
2463 date: Local::now(),
2464 files_changed: 0,
2465 insertions: 0,
2466 deletions: 0,
2467 score: 0.2,
2468 file_score: 0.0,
2469 change_score: 0.0,
2470 heat_score: 0.0,
2471 };
2472 assert_eq!(commit.score_color(), "green");
2473 }
2474
2475 #[test]
2476 fn test_commit_impact_score_bar() {
2477 let mut commit = CommitImpactScore {
2478 commit_hash: "abc".to_string(),
2479 commit_message: "test".to_string(),
2480 author: "Alice".to_string(),
2481 date: Local::now(),
2482 files_changed: 0,
2483 insertions: 0,
2484 deletions: 0,
2485 score: 0.0,
2486 file_score: 0.0,
2487 change_score: 0.0,
2488 heat_score: 0.0,
2489 };
2490
2491 commit.score = 0.9;
2492 assert_eq!(commit.score_bar(), "█████");
2493
2494 commit.score = 0.7;
2495 assert_eq!(commit.score_bar(), "████ ");
2496
2497 commit.score = 0.5;
2498 assert_eq!(commit.score_bar(), "███ ");
2499
2500 commit.score = 0.3;
2501 assert_eq!(commit.score_bar(), "██ ");
2502
2503 commit.score = 0.1;
2504 assert_eq!(commit.score_bar(), "█ ");
2505 }
2506
2507 #[test]
2508 fn test_calculate_impact_scores_high_impact_count() {
2509 let events = vec![
2510 create_test_event_with_hash_changes("a", "Alice", 400, 100), create_test_event_with_hash_changes("b", "Bob", 300, 100), create_test_event_with_hash_changes("c", "Carol", 10, 5), ];
2514 let refs: Vec<&GitEvent> = events.iter().collect();
2515 let heatmap = FileHeatmap::default();
2516
2517 let analysis = calculate_impact_scores(
2519 &refs,
2520 |hash| {
2521 if hash == "a" {
2522 Some((0..50).map(|i| format!("file{}.rs", i)).collect())
2523 } else {
2524 Some(vec!["file.rs".to_string()])
2525 }
2526 },
2527 &heatmap,
2528 );
2529
2530 assert!(analysis.high_impact_count >= 1);
2531 }
2532
2533 #[test]
2536 fn test_calculate_change_coupling_empty() {
2537 let events: Vec<&GitEvent> = vec![];
2538 let analysis = calculate_change_coupling(&events, |_| None, 5, 0.3);
2539 assert_eq!(analysis.coupling_count(), 0);
2540 assert_eq!(analysis.high_coupling_count, 0);
2541 assert_eq!(analysis.total_files_analyzed, 0);
2542 }
2543
2544 #[test]
2545 fn test_calculate_change_coupling_single_file() {
2546 let events = vec![
2548 create_test_event_with_hash("commit1"),
2549 create_test_event_with_hash("commit2"),
2550 create_test_event_with_hash("commit3"),
2551 create_test_event_with_hash("commit4"),
2552 create_test_event_with_hash("commit5"),
2553 ];
2554 let refs: Vec<&GitEvent> = events.iter().collect();
2555
2556 let analysis =
2557 calculate_change_coupling(&refs, |_| Some(vec!["src/main.rs".to_string()]), 1, 0.0);
2558 assert_eq!(analysis.coupling_count(), 0);
2559 }
2560
2561 #[test]
2562 fn test_calculate_change_coupling_pair() {
2563 let events = vec![
2565 create_test_event_with_hash("commit1"),
2566 create_test_event_with_hash("commit2"),
2567 create_test_event_with_hash("commit3"),
2568 create_test_event_with_hash("commit4"),
2569 create_test_event_with_hash("commit5"),
2570 ];
2571 let refs: Vec<&GitEvent> = events.iter().collect();
2572
2573 let analysis = calculate_change_coupling(
2574 &refs,
2575 |_| Some(vec!["src/app.rs".to_string(), "src/ui.rs".to_string()]),
2576 5,
2577 0.3,
2578 );
2579
2580 assert_eq!(analysis.coupling_count(), 2);
2582
2583 for coupling in &analysis.couplings {
2585 assert!((coupling.coupling_percent - 1.0).abs() < 0.01);
2586 assert_eq!(coupling.co_change_count, 5);
2587 assert_eq!(coupling.file_change_count, 5);
2588 }
2589 }
2590
2591 #[test]
2592 fn test_calculate_change_coupling_partial() {
2593 let events = vec![
2595 create_test_event_with_hash("commit1"),
2596 create_test_event_with_hash("commit2"),
2597 create_test_event_with_hash("commit3"),
2598 create_test_event_with_hash("commit4"),
2599 create_test_event_with_hash("commit5"),
2600 create_test_event_with_hash("commit6"),
2601 create_test_event_with_hash("commit7"),
2602 create_test_event_with_hash("commit8"),
2603 create_test_event_with_hash("commit9"),
2604 create_test_event_with_hash("commit10"),
2605 ];
2606 let refs: Vec<&GitEvent> = events.iter().collect();
2607
2608 let analysis = calculate_change_coupling(
2609 &refs,
2610 |hash| {
2611 if hash == "commit9" || hash == "commit10" {
2614 Some(vec!["src/app.rs".to_string()])
2615 } else {
2616 Some(vec!["src/app.rs".to_string(), "src/ui.rs".to_string()])
2617 }
2618 },
2619 5,
2620 0.3,
2621 );
2622
2623 let app_to_ui = analysis
2625 .couplings
2626 .iter()
2627 .find(|c| c.file == "src/app.rs" && c.coupled_file == "src/ui.rs");
2628 assert!(app_to_ui.is_some());
2629 let coupling = app_to_ui.unwrap();
2630 assert!((coupling.coupling_percent - 0.8).abs() < 0.01);
2631
2632 let ui_to_app = analysis
2634 .couplings
2635 .iter()
2636 .find(|c| c.file == "src/ui.rs" && c.coupled_file == "src/app.rs");
2637 assert!(ui_to_app.is_some());
2638 let coupling = ui_to_app.unwrap();
2639 assert!((coupling.coupling_percent - 1.0).abs() < 0.01);
2640 }
2641
2642 #[test]
2643 fn test_calculate_change_coupling_min_commits_filter() {
2644 let events = vec![
2645 create_test_event_with_hash("commit1"),
2646 create_test_event_with_hash("commit2"),
2647 create_test_event_with_hash("commit3"),
2648 ];
2649 let refs: Vec<&GitEvent> = events.iter().collect();
2650
2651 let analysis = calculate_change_coupling(
2653 &refs,
2654 |_| Some(vec!["src/app.rs".to_string(), "src/ui.rs".to_string()]),
2655 5,
2656 0.3,
2657 );
2658
2659 assert_eq!(analysis.coupling_count(), 0);
2660 }
2661
2662 #[test]
2663 fn test_calculate_change_coupling_min_coupling_filter() {
2664 let events = vec![
2665 create_test_event_with_hash("commit1"),
2666 create_test_event_with_hash("commit2"),
2667 create_test_event_with_hash("commit3"),
2668 create_test_event_with_hash("commit4"),
2669 create_test_event_with_hash("commit5"),
2670 ];
2671 let refs: Vec<&GitEvent> = events.iter().collect();
2672
2673 let analysis = calculate_change_coupling(
2674 &refs,
2675 |hash| {
2676 if hash == "commit1" {
2678 Some(vec!["src/app.rs".to_string(), "src/ui.rs".to_string()])
2679 } else {
2680 Some(vec!["src/app.rs".to_string()])
2681 }
2682 },
2683 1,
2684 0.3, );
2686
2687 let app_to_ui = analysis
2689 .couplings
2690 .iter()
2691 .find(|c| c.file == "src/app.rs" && c.coupled_file == "src/ui.rs");
2692 assert!(app_to_ui.is_none());
2693 }
2694
2695 #[test]
2696 fn test_calculate_change_coupling_high_coupling_count() {
2697 let events = vec![
2698 create_test_event_with_hash("commit1"),
2699 create_test_event_with_hash("commit2"),
2700 create_test_event_with_hash("commit3"),
2701 create_test_event_with_hash("commit4"),
2702 create_test_event_with_hash("commit5"),
2703 ];
2704 let refs: Vec<&GitEvent> = events.iter().collect();
2705
2706 let analysis = calculate_change_coupling(
2708 &refs,
2709 |_| Some(vec!["src/app.rs".to_string(), "src/ui.rs".to_string()]),
2710 5,
2711 0.3,
2712 );
2713
2714 assert_eq!(analysis.high_coupling_count, 1);
2716 }
2717
2718 #[test]
2719 fn test_file_coupling_bar() {
2720 let coupling = FileCoupling {
2721 file: "src/app.rs".to_string(),
2722 coupled_file: "src/ui.rs".to_string(),
2723 co_change_count: 8,
2724 file_change_count: 10,
2725 coupling_percent: 0.8,
2726 };
2727
2728 assert_eq!(coupling.coupling_bar(), "[████████░░]");
2730 }
2731
2732 #[test]
2733 fn test_change_coupling_grouped_by_file() {
2734 let analysis = ChangeCouplingAnalysis {
2735 couplings: vec![
2736 FileCoupling {
2737 file: "src/app.rs".to_string(),
2738 coupled_file: "src/ui.rs".to_string(),
2739 co_change_count: 8,
2740 file_change_count: 10,
2741 coupling_percent: 0.8,
2742 },
2743 FileCoupling {
2744 file: "src/app.rs".to_string(),
2745 coupled_file: "src/main.rs".to_string(),
2746 co_change_count: 5,
2747 file_change_count: 10,
2748 coupling_percent: 0.5,
2749 },
2750 FileCoupling {
2751 file: "src/git.rs".to_string(),
2752 coupled_file: "src/filter.rs".to_string(),
2753 co_change_count: 3,
2754 file_change_count: 5,
2755 coupling_percent: 0.6,
2756 },
2757 ],
2758 high_coupling_count: 1,
2759 total_files_analyzed: 5,
2760 };
2761
2762 let grouped = analysis.grouped_by_file();
2763 assert_eq!(grouped.len(), 2);
2764
2765 assert_eq!(grouped[0].0, "src/app.rs");
2767 assert_eq!(grouped[0].1.len(), 2);
2768
2769 assert_eq!(grouped[1].0, "src/git.rs");
2771 assert_eq!(grouped[1].1.len(), 1);
2772 }
2773
2774 #[test]
2777 fn test_calculate_message_quality_conventional() {
2778 let score = calculate_message_quality("feat: add new feature for user authentication");
2780 assert!(score >= 0.7, "Expected >= 0.7, got {}", score);
2781 }
2782
2783 #[test]
2784 fn test_calculate_message_quality_conventional_fix() {
2785 let score = calculate_message_quality("fix: resolve memory leak in connection pool");
2786 assert!(score >= 0.7, "Expected >= 0.7, got {}", score);
2787 }
2788
2789 #[test]
2790 fn test_calculate_message_quality_non_conventional() {
2791 let score = calculate_message_quality("Add user authentication feature");
2793 assert!(
2794 (0.3..0.7).contains(&score),
2795 "Expected 0.3-0.7, got {}",
2796 score
2797 );
2798 }
2799
2800 #[test]
2801 fn test_calculate_message_quality_short() {
2802 let score = calculate_message_quality("fix");
2804 assert!(score < 0.3, "Expected < 0.3, got {}", score);
2805 }
2806
2807 #[test]
2808 fn test_calculate_message_quality_empty() {
2809 let score = calculate_message_quality("");
2810 assert_eq!(score, 0.0);
2811 }
2812
2813 #[test]
2814 fn test_calculate_size_appropriateness_ideal() {
2815 let score = calculate_size_appropriateness(3, 60, 40);
2817 assert!(score >= 0.8, "Expected >= 0.8, got {}", score);
2818 }
2819
2820 #[test]
2821 fn test_calculate_size_appropriateness_large() {
2822 let score = calculate_size_appropriateness(20, 800, 200);
2824 assert!(score <= 0.2, "Expected <= 0.2, got {}", score);
2825 }
2826
2827 #[test]
2828 fn test_calculate_size_appropriateness_empty() {
2829 let score = calculate_size_appropriateness(0, 0, 0);
2831 assert_eq!(score, 0.0);
2832 }
2833
2834 #[test]
2835 fn test_calculate_test_presence_both() {
2836 let files = vec![
2838 "src/main.rs".to_string(),
2839 "src/lib.rs".to_string(),
2840 "tests/test_main.rs".to_string(),
2841 ];
2842 let score = calculate_test_presence(&files);
2843 assert_eq!(score, 1.0);
2844 }
2845
2846 #[test]
2847 fn test_calculate_test_presence_source_only() {
2848 let files = vec!["src/main.rs".to_string(), "src/lib.rs".to_string()];
2850 let score = calculate_test_presence(&files);
2851 assert!(score < 0.5, "Expected < 0.5, got {}", score);
2852 }
2853
2854 #[test]
2855 fn test_calculate_test_presence_test_only() {
2856 let files = vec![
2858 "tests/test_main.rs".to_string(),
2859 "src/module_test.rs".to_string(),
2860 ];
2861 let score = calculate_test_presence(&files);
2862 assert!(score >= 0.7, "Expected >= 0.7, got {}", score);
2863 }
2864
2865 #[test]
2866 fn test_calculate_test_presence_empty() {
2867 let score = calculate_test_presence(&[]);
2868 assert_eq!(score, 0.0);
2869 }
2870
2871 #[test]
2872 fn test_calculate_atomicity_single_file() {
2873 let files = vec!["src/main.rs".to_string()];
2874 let coupling = ChangeCouplingAnalysis::default();
2875 let score = calculate_atomicity(&files, &coupling);
2876 assert_eq!(score, 1.0);
2877 }
2878
2879 #[test]
2880 fn test_calculate_atomicity_same_directory() {
2881 let files = vec![
2882 "src/app.rs".to_string(),
2883 "src/lib.rs".to_string(),
2884 "src/main.rs".to_string(),
2885 ];
2886 let coupling = ChangeCouplingAnalysis::default();
2887 let score = calculate_atomicity(&files, &coupling);
2888 assert!(score >= 0.5, "Expected >= 0.5, got {}", score);
2889 }
2890
2891 #[test]
2892 fn test_calculate_atomicity_scattered() {
2893 let files = vec![
2894 "src/app.rs".to_string(),
2895 "tests/test.rs".to_string(),
2896 "docs/readme.md".to_string(),
2897 "config/settings.toml".to_string(),
2898 ];
2899 let coupling = ChangeCouplingAnalysis::default();
2900 let score = calculate_atomicity(&files, &coupling);
2901 assert!(score < 0.5, "Expected < 0.5, got {}", score);
2902 }
2903
2904 #[test]
2905 fn test_calculate_quality_scores_empty() {
2906 let events: Vec<&GitEvent> = vec![];
2907 let coupling = ChangeCouplingAnalysis::default();
2908 let analysis = calculate_quality_scores(&events, |_| None, &coupling);
2909 assert_eq!(analysis.total_commits, 0);
2910 assert_eq!(analysis.commits.len(), 0);
2911 assert_eq!(analysis.avg_score, 0.0);
2912 }
2913
2914 fn create_test_event_for_quality(
2915 hash: &str,
2916 message: &str,
2917 insertions: usize,
2918 deletions: usize,
2919 ) -> GitEvent {
2920 GitEvent::commit(
2921 hash.to_string(),
2922 message.to_string(),
2923 "author".to_string(),
2924 Local::now(),
2925 insertions,
2926 deletions,
2927 )
2928 }
2929
2930 #[test]
2931 fn test_calculate_quality_scores_single_commit() {
2932 let events = vec![create_test_event_for_quality(
2933 "abc1234",
2934 "feat: add authentication feature",
2935 50,
2936 10,
2937 )];
2938 let refs: Vec<&GitEvent> = events.iter().collect();
2939 let coupling = ChangeCouplingAnalysis::default();
2940
2941 let analysis = calculate_quality_scores(
2942 &refs,
2943 |_| {
2944 Some(vec![
2945 "src/auth.rs".to_string(),
2946 "tests/auth_test.rs".to_string(),
2947 ])
2948 },
2949 &coupling,
2950 );
2951
2952 assert_eq!(analysis.total_commits, 1);
2953 assert_eq!(analysis.commits.len(), 1);
2954
2955 let commit = &analysis.commits[0];
2956 assert_eq!(commit.commit_hash, "abc1234");
2957 assert!(commit.score > 0.0);
2958 assert!(commit.message_score > 0.0);
2959 assert!(commit.size_score > 0.0);
2960 assert!(commit.test_score > 0.0);
2961 }
2962
2963 #[test]
2964 fn test_calculate_quality_scores_sorted_by_score() {
2965 let events = vec![
2966 create_test_event_for_quality("low", "wip", 500, 500), create_test_event_for_quality("high", "feat: excellent commit with tests", 50, 20), create_test_event_for_quality("medium", "update files", 100, 50), ];
2970 let refs: Vec<&GitEvent> = events.iter().collect();
2971 let coupling = ChangeCouplingAnalysis::default();
2972
2973 let analysis = calculate_quality_scores(
2974 &refs,
2975 |hash| {
2976 if hash == "high" {
2977 Some(vec![
2978 "src/feature.rs".to_string(),
2979 "tests/feature_test.rs".to_string(),
2980 ])
2981 } else {
2982 Some(vec!["src/main.rs".to_string()])
2983 }
2984 },
2985 &coupling,
2986 );
2987
2988 assert_eq!(analysis.commits.len(), 3);
2989 assert!(analysis.commits[0].score >= analysis.commits[1].score);
2991 assert!(analysis.commits[1].score >= analysis.commits[2].score);
2992 }
2993
2994 #[test]
2995 fn test_commit_quality_score_color() {
2996 let mut commit = CommitQualityScore {
2997 commit_hash: "abc".to_string(),
2998 commit_message: "test".to_string(),
2999 author: "author".to_string(),
3000 date: Local::now(),
3001 files_changed: 0,
3002 insertions: 0,
3003 deletions: 0,
3004 score: 0.0,
3005 message_score: 0.0,
3006 size_score: 0.0,
3007 test_score: 0.0,
3008 atomicity_score: 0.0,
3009 };
3010
3011 commit.score = 0.9;
3013 assert_eq!(commit.score_color(), "green");
3014
3015 commit.score = 0.7;
3016 assert_eq!(commit.score_color(), "green");
3017
3018 commit.score = 0.6;
3019 assert_eq!(commit.score_color(), "green");
3020
3021 commit.score = 0.5;
3023 assert_eq!(commit.score_color(), "yellow");
3024
3025 commit.score = 0.4;
3026 assert_eq!(commit.score_color(), "yellow");
3027
3028 commit.score = 0.3;
3030 assert_eq!(commit.score_color(), "red");
3031
3032 commit.score = 0.2;
3033 assert_eq!(commit.score_color(), "red");
3034 }
3035
3036 #[test]
3037 fn test_commit_quality_score_bar() {
3038 let mut commit = CommitQualityScore {
3039 commit_hash: "abc".to_string(),
3040 commit_message: "test".to_string(),
3041 author: "author".to_string(),
3042 date: Local::now(),
3043 files_changed: 0,
3044 insertions: 0,
3045 deletions: 0,
3046 score: 0.0,
3047 message_score: 0.0,
3048 size_score: 0.0,
3049 test_score: 0.0,
3050 atomicity_score: 0.0,
3051 };
3052
3053 commit.score = 0.9;
3054 assert_eq!(commit.score_bar(), "█████");
3055
3056 commit.score = 0.7;
3057 assert_eq!(commit.score_bar(), "████ ");
3058
3059 commit.score = 0.5;
3060 assert_eq!(commit.score_bar(), "███ ");
3061
3062 commit.score = 0.3;
3063 assert_eq!(commit.score_bar(), "██ ");
3064
3065 commit.score = 0.1;
3066 assert_eq!(commit.score_bar(), "█ ");
3067 }
3068
3069 #[test]
3070 fn test_commit_quality_score_quality_level() {
3071 let mut commit = CommitQualityScore {
3072 commit_hash: "abc".to_string(),
3073 commit_message: "test".to_string(),
3074 author: "author".to_string(),
3075 date: Local::now(),
3076 files_changed: 0,
3077 insertions: 0,
3078 deletions: 0,
3079 score: 0.0,
3080 message_score: 0.0,
3081 size_score: 0.0,
3082 test_score: 0.0,
3083 atomicity_score: 0.0,
3084 };
3085
3086 commit.score = 0.9;
3087 assert_eq!(commit.quality_level(), "Excellent");
3088
3089 commit.score = 0.7;
3090 assert_eq!(commit.quality_level(), "Good");
3091
3092 commit.score = 0.5;
3093 assert_eq!(commit.quality_level(), "Fair");
3094
3095 commit.score = 0.2;
3096 assert_eq!(commit.quality_level(), "Poor");
3097 }
3098
3099 #[test]
3100 fn test_quality_analysis_high_low_counts() {
3101 let events = vec![
3102 create_test_event_for_quality("high1", "feat: great feature", 30, 10),
3103 create_test_event_for_quality("high2", "fix: important fix", 20, 5),
3104 create_test_event_for_quality("low1", "x", 1000, 1000),
3105 create_test_event_for_quality("low2", ".", 2000, 500),
3106 ];
3107 let refs: Vec<&GitEvent> = events.iter().collect();
3108 let coupling = ChangeCouplingAnalysis::default();
3109
3110 let analysis = calculate_quality_scores(
3111 &refs,
3112 |hash| {
3113 if hash == "high1" || hash == "high2" {
3114 Some(vec![
3115 "src/feature.rs".to_string(),
3116 "tests/feature_test.rs".to_string(),
3117 ])
3118 } else {
3119 Some((0..50).map(|i| format!("file{}.rs", i)).collect())
3120 }
3121 },
3122 &coupling,
3123 );
3124
3125 assert_eq!(analysis.total_commits, 4);
3126 assert!(analysis.high_quality_count > 0 || analysis.low_quality_count > 0);
3128 }
3129
3130 #[test]
3133 fn test_project_health_default() {
3134 let health = ProjectHealth::default();
3135 assert_eq!(health.overall_score, 50);
3136 assert_eq!(health.level(), "Fair");
3137 }
3138
3139 #[test]
3140 fn test_project_health_level() {
3141 let health = ProjectHealth {
3142 overall_score: 90,
3143 ..Default::default()
3144 };
3145 assert_eq!(health.level(), "Excellent");
3146
3147 let health = ProjectHealth {
3148 overall_score: 75,
3149 ..Default::default()
3150 };
3151 assert_eq!(health.level(), "Good");
3152
3153 let health = ProjectHealth {
3154 overall_score: 50,
3155 ..Default::default()
3156 };
3157 assert_eq!(health.level(), "Fair");
3158
3159 let health = ProjectHealth {
3160 overall_score: 20,
3161 ..Default::default()
3162 };
3163 assert_eq!(health.level(), "Poor");
3164 }
3165
3166 #[test]
3167 fn test_project_health_score_bar() {
3168 let health = ProjectHealth {
3169 overall_score: 100,
3170 ..Default::default()
3171 };
3172 assert_eq!(health.score_bar(), "██████████");
3173
3174 let health = ProjectHealth {
3175 overall_score: 50,
3176 ..Default::default()
3177 };
3178 assert_eq!(health.score_bar(), "█████░░░░░");
3179
3180 let health = ProjectHealth {
3181 overall_score: 0,
3182 ..Default::default()
3183 };
3184 assert_eq!(health.score_bar(), "░░░░░░░░░░");
3185 }
3186
3187 #[test]
3188 fn test_alert_severity_ordering() {
3189 assert!(AlertSeverity::Critical > AlertSeverity::Warning);
3190 assert!(AlertSeverity::Warning > AlertSeverity::Info);
3191 }
3192
3193 #[test]
3194 fn test_calculate_project_health_empty() {
3195 let events: Vec<&GitEvent> = vec![];
3196 let health = calculate_project_health(&events, |_| None, None, None, None);
3197 assert_eq!(health.overall_score, 50);
3198 assert_eq!(health.total_commits, 0);
3199 }
3200
3201 #[test]
3202 fn test_calculate_project_health_with_events() {
3203 let events = vec![
3204 create_test_event_for_quality("hash1", "feat: add new feature", 50, 10),
3205 create_test_event_for_quality("hash2", "fix: bug fix", 20, 5),
3206 create_test_event_for_quality("hash3", "test: add tests", 30, 0),
3207 ];
3208 let refs: Vec<&GitEvent> = events.iter().collect();
3209
3210 let health = calculate_project_health(&refs, |_| None, None, None, None);
3211
3212 assert_eq!(health.total_commits, 3);
3213 assert!(health.overall_score > 0);
3214 assert!(health.quality.score > 0.5);
3216 }
3217
3218 #[test]
3219 fn test_calculate_project_health_alerts() {
3220 let mut events = Vec::new();
3222 for i in 0..10 {
3223 let mut event = create_test_event("single_author", 10, 5);
3224 event.short_hash = format!("hash{}", i);
3225 events.push(event);
3226 }
3227 let refs: Vec<&GitEvent> = events.iter().collect();
3228
3229 let health = calculate_project_health(&refs, |_| None, None, None, None);
3230
3231 assert!(!health.alerts.is_empty());
3233 let has_bus_factor_alert = health
3234 .alerts
3235 .iter()
3236 .any(|a| a.message.contains("bus factor"));
3237 assert!(has_bus_factor_alert);
3238 }
3239}