1use chrono::{DateTime, Local};
6use std::collections::HashMap;
7
8use crate::event::GitEvent;
9
10#[derive(Debug, Clone)]
16pub struct CommitImpactScore {
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 file_score: f64,
35 pub change_score: f64,
37 pub heat_score: f64,
39}
40
41impl CommitImpactScore {
42 pub fn score_color(&self) -> &'static str {
44 if self.score >= 0.7 {
45 "red" } else if self.score >= 0.4 {
47 "yellow" } else {
49 "green" }
51 }
52
53 pub fn score_bar(&self) -> &'static str {
55 if self.score >= 0.8 {
56 "█████"
57 } else if self.score >= 0.6 {
58 "████ "
59 } else if self.score >= 0.4 {
60 "███ "
61 } else if self.score >= 0.2 {
62 "██ "
63 } else {
64 "█ "
65 }
66 }
67}
68
69#[derive(Debug, Clone, Default)]
71pub struct CommitImpactAnalysis {
72 pub commits: Vec<CommitImpactScore>,
74 pub total_commits: usize,
76 pub avg_score: f64,
78 pub max_score: f64,
80 pub high_impact_count: usize,
82}
83
84impl CommitImpactAnalysis {
85 pub fn commit_count(&self) -> usize {
87 self.commits.len()
88 }
89}
90
91pub fn calculate_impact_scores(
98 events: &[&GitEvent],
99 get_files: impl Fn(&str) -> Option<Vec<String>>,
100 file_heatmap: &FileHeatmap,
101) -> CommitImpactAnalysis {
102 let mut commits = Vec::new();
103 let mut total_score = 0.0;
104 let mut max_score = 0.0f64;
105 let mut high_impact_count = 0;
106
107 let heat_map: HashMap<&str, f64> = file_heatmap
109 .files
110 .iter()
111 .map(|f| (f.path.as_str(), f.heat_level()))
112 .collect();
113
114 for event in events {
115 let files = get_files(&event.short_hash).unwrap_or_default();
116 let files_changed = files.len();
117 let total_changes = event.files_added + event.files_deleted;
118
119 let file_score = (files_changed as f64 / 50.0).min(1.0) * 0.4;
121
122 let change_score = (total_changes as f64 / 500.0).min(1.0) * 0.4;
124
125 let avg_file_heat = if files.is_empty() {
127 0.0
128 } else {
129 let total_heat: f64 = files
130 .iter()
131 .map(|f| heat_map.get(f.as_str()).copied().unwrap_or(0.0))
132 .sum();
133 total_heat / files.len() as f64
134 };
135 let heat_score = avg_file_heat * 0.2;
136
137 let score = file_score + change_score + heat_score;
139
140 commits.push(CommitImpactScore {
141 commit_hash: event.short_hash.clone(),
142 commit_message: event.message.clone(),
143 author: event.author.clone(),
144 date: event.timestamp,
145 files_changed,
146 insertions: event.files_added,
147 deletions: event.files_deleted,
148 score,
149 file_score,
150 change_score,
151 heat_score,
152 });
153
154 total_score += score;
155 max_score = max_score.max(score);
156 if score >= 0.7 {
157 high_impact_count += 1;
158 }
159 }
160
161 commits.sort_by(|a, b| {
163 b.score
164 .partial_cmp(&a.score)
165 .unwrap_or(std::cmp::Ordering::Equal)
166 });
167
168 let total_commits = commits.len();
169 let avg_score = if total_commits > 0 {
170 total_score / total_commits as f64
171 } else {
172 0.0
173 };
174
175 CommitImpactAnalysis {
176 commits,
177 total_commits,
178 avg_score,
179 max_score,
180 high_impact_count,
181 }
182}
183
184#[derive(Debug, Clone)]
190pub struct FileCoupling {
191 pub file: String,
193 pub coupled_file: String,
195 pub co_change_count: usize,
197 pub file_change_count: usize,
199 pub coupling_percent: f64,
201}
202
203impl FileCoupling {
204 pub fn coupling_bar(&self) -> String {
206 let filled = (self.coupling_percent * 10.0).round() as usize;
207 let empty = 10 - filled;
208 format!("[{}{}]", "█".repeat(filled), "░".repeat(empty))
209 }
210}
211
212#[derive(Debug, Clone, Default)]
214pub struct ChangeCouplingAnalysis {
215 pub couplings: Vec<FileCoupling>,
217 pub high_coupling_count: usize,
219 pub total_files_analyzed: usize,
221}
222
223impl ChangeCouplingAnalysis {
224 pub fn coupling_count(&self) -> usize {
226 self.couplings.len()
227 }
228
229 pub fn grouped_by_file(&self) -> Vec<(&str, Vec<&FileCoupling>)> {
231 use std::collections::HashMap;
232 let mut groups: HashMap<&str, Vec<&FileCoupling>> = HashMap::new();
233 for coupling in &self.couplings {
234 groups.entry(&coupling.file).or_default().push(coupling);
235 }
236 let mut result: Vec<_> = groups.into_iter().collect();
237 result.sort_by(|a, b| {
239 let count_a = a.1.first().map(|c| c.file_change_count).unwrap_or(0);
240 let count_b = b.1.first().map(|c| c.file_change_count).unwrap_or(0);
241 count_b.cmp(&count_a)
242 });
243 result
244 }
245}
246
247pub fn calculate_change_coupling(
258 events: &[&GitEvent],
259 get_files: impl Fn(&str) -> Option<Vec<String>>,
260 min_commits: usize,
261 min_coupling: f64,
262) -> ChangeCouplingAnalysis {
263 use std::collections::{HashMap, HashSet};
264
265 let mut file_change_counts: HashMap<String, usize> = HashMap::new();
267
268 let mut commit_files: Vec<HashSet<String>> = Vec::new();
270
271 for event in events {
272 if let Some(files) = get_files(&event.short_hash) {
273 let file_set: HashSet<String> = files.iter().cloned().collect();
274 for file in &file_set {
275 *file_change_counts.entry(file.clone()).or_insert(0) += 1;
276 }
277 commit_files.push(file_set);
278 }
279 }
280
281 let mut pair_counts: HashMap<(String, String), usize> = HashMap::new();
283
284 for files in &commit_files {
285 let files_vec: Vec<_> = files.iter().collect();
286 for i in 0..files_vec.len() {
287 for j in (i + 1)..files_vec.len() {
288 let file_a = files_vec[i].clone();
289 let file_b = files_vec[j].clone();
290 *pair_counts
292 .entry((file_a.clone(), file_b.clone()))
293 .or_insert(0) += 1;
294 *pair_counts.entry((file_b, file_a)).or_insert(0) += 1;
295 }
296 }
297 }
298
299 let mut couplings: Vec<FileCoupling> = Vec::new();
301 let mut high_coupling_count = 0;
302 let mut analyzed_files: HashSet<String> = HashSet::new();
303
304 for ((file, coupled_file), co_change_count) in &pair_counts {
305 let file_change_count = *file_change_counts.get(file).unwrap_or(&0);
306
307 if file_change_count < min_commits {
309 continue;
310 }
311
312 let coupling_percent = *co_change_count as f64 / file_change_count as f64;
313
314 if coupling_percent < min_coupling {
316 continue;
317 }
318
319 analyzed_files.insert(file.clone());
320
321 if coupling_percent >= 0.7 {
322 high_coupling_count += 1;
323 }
324
325 couplings.push(FileCoupling {
326 file: file.clone(),
327 coupled_file: coupled_file.clone(),
328 co_change_count: *co_change_count,
329 file_change_count,
330 coupling_percent,
331 });
332 }
333
334 couplings.sort_by(|a, b| {
336 b.coupling_percent
337 .partial_cmp(&a.coupling_percent)
338 .unwrap_or(std::cmp::Ordering::Equal)
339 });
340
341 high_coupling_count /= 2;
343
344 ChangeCouplingAnalysis {
345 couplings,
346 high_coupling_count,
347 total_files_analyzed: analyzed_files.len(),
348 }
349}
350
351#[derive(Debug, Clone)]
353pub struct AuthorStats {
354 pub name: String,
356 pub commit_count: usize,
358 pub insertions: usize,
360 pub deletions: usize,
362 pub last_commit: DateTime<Local>,
364}
365
366impl AuthorStats {
367 pub fn commit_percentage(&self, total: usize) -> f64 {
369 if total == 0 {
370 0.0
371 } else {
372 (self.commit_count as f64 / total as f64) * 100.0
373 }
374 }
375}
376
377#[derive(Debug, Clone, Default)]
379pub struct RepoStats {
380 pub authors: Vec<AuthorStats>,
382 pub total_commits: usize,
384 pub total_insertions: usize,
386 pub total_deletions: usize,
388}
389
390impl RepoStats {
391 pub fn author_count(&self) -> usize {
393 self.authors.len()
394 }
395}
396
397#[derive(Debug, Clone)]
399pub struct FileHeatmapEntry {
400 pub path: String,
402 pub change_count: usize,
404 pub max_changes: usize,
406}
407
408impl FileHeatmapEntry {
409 pub fn heat_level(&self) -> f64 {
411 if self.max_changes == 0 {
412 0.0
413 } else {
414 self.change_count as f64 / self.max_changes as f64
415 }
416 }
417
418 pub fn heat_bar(&self) -> &'static str {
420 let level = self.heat_level();
421 if level >= 0.8 {
422 "█████"
423 } else if level >= 0.6 {
424 "████ "
425 } else if level >= 0.4 {
426 "███ "
427 } else if level >= 0.2 {
428 "██ "
429 } else {
430 "█ "
431 }
432 }
433}
434
435#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
437pub enum AggregationLevel {
438 #[default]
440 Files,
441 Shallow,
443 Deep,
445}
446
447impl AggregationLevel {
448 pub fn next(&self) -> Self {
450 match self {
451 AggregationLevel::Files => AggregationLevel::Shallow,
452 AggregationLevel::Shallow => AggregationLevel::Deep,
453 AggregationLevel::Deep => AggregationLevel::Files,
454 }
455 }
456
457 pub fn prev(&self) -> Self {
459 match self {
460 AggregationLevel::Files => AggregationLevel::Deep,
461 AggregationLevel::Shallow => AggregationLevel::Files,
462 AggregationLevel::Deep => AggregationLevel::Shallow,
463 }
464 }
465
466 pub fn display_name(&self) -> &'static str {
468 match self {
469 AggregationLevel::Files => "Files",
470 AggregationLevel::Shallow => "Directories (2 levels)",
471 AggregationLevel::Deep => "Directories (top level)",
472 }
473 }
474}
475
476#[derive(Debug, Clone, Default)]
478pub struct FileHeatmap {
479 pub files: Vec<FileHeatmapEntry>,
481 pub total_files: usize,
483 pub aggregation_level: AggregationLevel,
485}
486
487impl FileHeatmap {
488 pub fn file_count(&self) -> usize {
490 self.files.len()
491 }
492
493 pub fn with_aggregation(&self, level: AggregationLevel) -> FileHeatmap {
495 if level == AggregationLevel::Files {
496 return FileHeatmap {
498 files: self.files.clone(),
499 total_files: self.total_files,
500 aggregation_level: level,
501 };
502 }
503
504 let mut dir_counts: HashMap<String, usize> = HashMap::new();
506
507 for entry in &self.files {
508 let dir = extract_directory(&entry.path, level);
509 *dir_counts.entry(dir).or_insert(0) += entry.change_count;
510 }
511
512 let max_changes = dir_counts.values().copied().max().unwrap_or(0);
513
514 let mut files: Vec<FileHeatmapEntry> = dir_counts
515 .into_iter()
516 .map(|(path, change_count)| FileHeatmapEntry {
517 path,
518 change_count,
519 max_changes,
520 })
521 .collect();
522
523 files.sort_by(|a, b| b.change_count.cmp(&a.change_count));
525
526 FileHeatmap {
527 total_files: self.total_files,
528 files,
529 aggregation_level: level,
530 }
531 }
532}
533
534fn extract_directory(path: &str, level: AggregationLevel) -> String {
536 let parts: Vec<&str> = path.split('/').collect();
537 match level {
538 AggregationLevel::Files => path.to_string(),
539 AggregationLevel::Shallow => {
540 if parts.len() > 2 {
542 format!("{}/{}/", parts[0], parts[1])
543 } else if parts.len() == 2 {
544 format!("{}/", parts[0])
545 } else {
546 path.to_string()
547 }
548 }
549 AggregationLevel::Deep => {
550 if parts.len() > 1 {
552 format!("{}/", parts[0])
553 } else {
554 path.to_string()
555 }
556 }
557 }
558}
559
560pub fn calculate_file_heatmap(
562 events: &[&GitEvent],
563 get_files: impl Fn(&str) -> Option<Vec<String>>,
564) -> FileHeatmap {
565 let mut file_counts: HashMap<String, usize> = HashMap::new();
566
567 for event in events {
568 if let Some(files) = get_files(&event.short_hash) {
569 for file in files {
570 *file_counts.entry(file).or_insert(0) += 1;
571 }
572 }
573 }
574
575 let max_changes = file_counts.values().copied().max().unwrap_or(0);
576
577 let mut files: Vec<FileHeatmapEntry> = file_counts
578 .into_iter()
579 .map(|(path, change_count)| FileHeatmapEntry {
580 path,
581 change_count,
582 max_changes,
583 })
584 .collect();
585
586 files.sort_by(|a, b| b.change_count.cmp(&a.change_count));
588
589 let total_files = files.len();
590 FileHeatmap {
591 files,
592 total_files,
593 aggregation_level: AggregationLevel::Files,
594 }
595}
596
597#[derive(Debug, Clone, Default)]
599pub struct ActivityTimeline {
600 pub grid: [[usize; 24]; 7],
604 pub total_commits: usize,
606 pub peak_day: usize,
608 pub peak_hour: usize,
610 pub peak_count: usize,
612 pub max_count: usize,
614}
615
616impl ActivityTimeline {
617 pub fn day_name(day: usize) -> &'static str {
619 match day {
620 0 => "Mon",
621 1 => "Tue",
622 2 => "Wed",
623 3 => "Thu",
624 4 => "Fri",
625 5 => "Sat",
626 6 => "Sun",
627 _ => "???",
628 }
629 }
630
631 pub fn heat_level(&self, day: usize, hour: usize) -> f64 {
633 if self.max_count == 0 {
634 0.0
635 } else {
636 self.grid[day][hour] as f64 / self.max_count as f64
637 }
638 }
639
640 pub fn heat_char(level: f64) -> &'static str {
642 if level >= 0.8 {
643 "██"
644 } else if level >= 0.6 {
645 "▓▓"
646 } else if level >= 0.4 {
647 "▒▒"
648 } else if level >= 0.2 {
649 "░░"
650 } else if level > 0.0 {
651 "··"
652 } else {
653 " "
654 }
655 }
656
657 pub fn peak_summary(&self) -> String {
659 if self.peak_count == 0 {
660 "No activity".to_string()
661 } else {
662 format!(
663 "{} {:02}:00-{:02}:00 ({} commits)",
664 Self::day_name(self.peak_day),
665 self.peak_hour,
666 (self.peak_hour + 1) % 24,
667 self.peak_count
668 )
669 }
670 }
671}
672
673pub fn calculate_activity_timeline(events: &[&GitEvent]) -> ActivityTimeline {
675 use chrono::Datelike;
676 use chrono::Timelike;
677
678 let mut timeline = ActivityTimeline {
679 total_commits: events.len(),
680 ..Default::default()
681 };
682
683 for event in events {
684 let day = event.timestamp.weekday().num_days_from_monday() as usize;
686 let hour = event.timestamp.hour() as usize;
687
688 timeline.grid[day][hour] += 1;
689 }
690
691 let mut max_count = 0usize;
693 for (day, hours) in timeline.grid.iter().enumerate() {
694 for (hour, &count) in hours.iter().enumerate() {
695 if count > max_count {
696 max_count = count;
697 timeline.peak_day = day;
698 timeline.peak_hour = hour;
699 timeline.peak_count = count;
700 }
701 }
702 }
703 timeline.max_count = max_count;
704
705 timeline
706}
707
708#[derive(Debug, Clone)]
710pub struct CodeOwnershipEntry {
711 pub path: String,
713 pub primary_author: String,
715 pub primary_commits: usize,
717 pub total_commits: usize,
719 pub depth: usize,
721 pub is_directory: bool,
723}
724
725impl CodeOwnershipEntry {
726 pub fn ownership_percentage(&self) -> f64 {
728 if self.total_commits == 0 {
729 0.0
730 } else {
731 (self.primary_commits as f64 / self.total_commits as f64) * 100.0
732 }
733 }
734}
735
736#[derive(Debug, Clone, Default)]
738pub struct CodeOwnership {
739 pub entries: Vec<CodeOwnershipEntry>,
741 pub total_files: usize,
743}
744
745impl CodeOwnership {
746 pub fn entry_count(&self) -> usize {
748 self.entries.len()
749 }
750}
751
752pub fn calculate_ownership(
754 events: &[&GitEvent],
755 get_files: impl Fn(&str) -> Option<Vec<String>>,
756) -> CodeOwnership {
757 let mut file_author_counts: HashMap<String, HashMap<String, usize>> = HashMap::new();
759
760 for event in events {
761 if let Some(files) = get_files(&event.short_hash) {
762 for file in files {
763 let author_counts = file_author_counts.entry(file).or_default();
764 *author_counts.entry(event.author.clone()).or_insert(0) += 1;
765 }
766 }
767 }
768
769 let mut dir_author_counts: HashMap<String, HashMap<String, usize>> = HashMap::new();
771
772 for (file_path, author_counts) in &file_author_counts {
773 let parts: Vec<&str> = file_path.split('/').collect();
775 for i in 1..parts.len() {
776 let dir_path = parts[..i].join("/");
777 let dir_counts = dir_author_counts.entry(dir_path).or_default();
778 for (author, count) in author_counts {
779 *dir_counts.entry(author.clone()).or_insert(0) += count;
780 }
781 }
782 }
783
784 let mut entries = Vec::new();
786
787 let mut dir_paths: Vec<String> = dir_author_counts.keys().cloned().collect();
789 dir_paths.sort();
790
791 for dir_path in dir_paths {
792 let author_counts = &dir_author_counts[&dir_path];
793 let (primary_author, primary_commits) = author_counts
794 .iter()
795 .max_by_key(|(_, c)| *c)
796 .map(|(a, c)| (a.clone(), *c))
797 .unwrap_or_default();
798 let total_commits: usize = author_counts.values().sum();
799 let depth = dir_path.matches('/').count();
800
801 entries.push(CodeOwnershipEntry {
802 path: dir_path,
803 primary_author,
804 primary_commits,
805 total_commits,
806 depth,
807 is_directory: true,
808 });
809 }
810
811 let mut file_paths: Vec<String> = file_author_counts.keys().cloned().collect();
813 file_paths.sort();
814
815 for file_path in file_paths {
816 let author_counts = &file_author_counts[&file_path];
817 let (primary_author, primary_commits) = author_counts
818 .iter()
819 .max_by_key(|(_, c)| *c)
820 .map(|(a, c)| (a.clone(), *c))
821 .unwrap_or_default();
822 let total_commits: usize = author_counts.values().sum();
823 let depth = file_path.matches('/').count();
824
825 entries.push(CodeOwnershipEntry {
826 path: file_path,
827 primary_author,
828 primary_commits,
829 total_commits,
830 depth,
831 is_directory: false,
832 });
833 }
834
835 entries.sort_by(|a, b| a.path.cmp(&b.path));
837
838 let total_files = file_author_counts.len();
839 CodeOwnership {
840 entries,
841 total_files,
842 }
843}
844
845pub fn calculate_stats(events: &[&GitEvent]) -> RepoStats {
847 let mut author_map: HashMap<String, AuthorStats> = HashMap::new();
848 let mut total_insertions = 0usize;
849 let mut total_deletions = 0usize;
850
851 for event in events {
852 total_insertions += event.files_added;
853 total_deletions += event.files_deleted;
854
855 let entry = author_map
856 .entry(event.author.clone())
857 .or_insert(AuthorStats {
858 name: event.author.clone(),
859 commit_count: 0,
860 insertions: 0,
861 deletions: 0,
862 last_commit: event.timestamp,
863 });
864
865 entry.commit_count += 1;
866 entry.insertions += event.files_added;
867 entry.deletions += event.files_deleted;
868
869 if event.timestamp > entry.last_commit {
871 entry.last_commit = event.timestamp;
872 }
873 }
874
875 let mut authors: Vec<AuthorStats> = author_map.into_values().collect();
877 authors.sort_by(|a, b| b.commit_count.cmp(&a.commit_count));
878
879 RepoStats {
880 authors,
881 total_commits: events.len(),
882 total_insertions,
883 total_deletions,
884 }
885}
886
887#[cfg(test)]
888#[allow(clippy::useless_vec)]
889mod tests {
890 use super::*;
891 use chrono::Local;
892
893 fn create_test_event(author: &str, insertions: usize, deletions: usize) -> GitEvent {
894 GitEvent::commit(
895 "abc1234".to_string(),
896 "test commit".to_string(),
897 author.to_string(),
898 Local::now(),
899 insertions,
900 deletions,
901 )
902 }
903
904 #[test]
905 fn test_calculate_stats_empty() {
906 let stats = calculate_stats(&[]);
907 assert_eq!(stats.total_commits, 0);
908 assert_eq!(stats.authors.len(), 0);
909 }
910
911 #[test]
912 fn test_calculate_stats_single_author() {
913 let events = vec![
914 create_test_event("Alice", 10, 5),
915 create_test_event("Alice", 20, 10),
916 ];
917 let refs: Vec<&GitEvent> = events.iter().collect();
918 let stats = calculate_stats(&refs);
919
920 assert_eq!(stats.total_commits, 2);
921 assert_eq!(stats.authors.len(), 1);
922 assert_eq!(stats.authors[0].name, "Alice");
923 assert_eq!(stats.authors[0].commit_count, 2);
924 assert_eq!(stats.authors[0].insertions, 30);
925 assert_eq!(stats.authors[0].deletions, 15);
926 }
927
928 #[test]
929 fn test_calculate_stats_multiple_authors() {
930 let events = vec![
931 create_test_event("Alice", 10, 5),
932 create_test_event("Bob", 5, 2),
933 create_test_event("Alice", 20, 10),
934 create_test_event("Bob", 15, 8),
935 create_test_event("Bob", 10, 5),
936 ];
937 let refs: Vec<&GitEvent> = events.iter().collect();
938 let stats = calculate_stats(&refs);
939
940 assert_eq!(stats.total_commits, 5);
941 assert_eq!(stats.authors.len(), 2);
942
943 assert_eq!(stats.authors[0].name, "Bob");
945 assert_eq!(stats.authors[0].commit_count, 3);
946
947 assert_eq!(stats.authors[1].name, "Alice");
949 assert_eq!(stats.authors[1].commit_count, 2);
950 }
951
952 #[test]
953 fn test_calculate_stats_totals() {
954 let events = vec![
955 create_test_event("Alice", 10, 5),
956 create_test_event("Bob", 20, 10),
957 ];
958 let refs: Vec<&GitEvent> = events.iter().collect();
959 let stats = calculate_stats(&refs);
960
961 assert_eq!(stats.total_insertions, 30);
962 assert_eq!(stats.total_deletions, 15);
963 }
964
965 #[test]
966 fn test_author_stats_commit_percentage() {
967 let author = AuthorStats {
968 name: "Alice".to_string(),
969 commit_count: 25,
970 insertions: 0,
971 deletions: 0,
972 last_commit: Local::now(),
973 };
974
975 assert!((author.commit_percentage(100) - 25.0).abs() < 0.01);
976 assert!((author.commit_percentage(50) - 50.0).abs() < 0.01);
977 }
978
979 #[test]
980 fn test_author_stats_commit_percentage_zero() {
981 let author = AuthorStats {
982 name: "Alice".to_string(),
983 commit_count: 10,
984 insertions: 0,
985 deletions: 0,
986 last_commit: Local::now(),
987 };
988
989 assert_eq!(author.commit_percentage(0), 0.0);
990 }
991
992 #[test]
993 fn test_repo_stats_author_count() {
994 let events = vec![
995 create_test_event("Alice", 10, 5),
996 create_test_event("Bob", 5, 2),
997 create_test_event("Charlie", 15, 8),
998 ];
999 let refs: Vec<&GitEvent> = events.iter().collect();
1000 let stats = calculate_stats(&refs);
1001
1002 assert_eq!(stats.author_count(), 3);
1003 }
1004
1005 fn create_test_event_with_hash(hash: &str) -> GitEvent {
1008 GitEvent::commit(
1009 hash.to_string(),
1010 "test commit".to_string(),
1011 "author".to_string(),
1012 Local::now(),
1013 10,
1014 5,
1015 )
1016 }
1017
1018 #[test]
1019 fn test_calculate_file_heatmap_empty() {
1020 let events: Vec<&GitEvent> = vec![];
1021 let heatmap = calculate_file_heatmap(&events, |_| None);
1022 assert_eq!(heatmap.file_count(), 0);
1023 }
1024
1025 #[test]
1026 fn test_calculate_file_heatmap_single_file() {
1027 let events = vec![
1028 create_test_event_with_hash("abc1234"),
1029 create_test_event_with_hash("def5678"),
1030 ];
1031 let refs: Vec<&GitEvent> = events.iter().collect();
1032 let heatmap = calculate_file_heatmap(&refs, |_| Some(vec!["src/main.rs".to_string()]));
1033
1034 assert_eq!(heatmap.file_count(), 1);
1035 assert_eq!(heatmap.files[0].path, "src/main.rs");
1036 assert_eq!(heatmap.files[0].change_count, 2);
1037 }
1038
1039 #[test]
1040 fn test_calculate_file_heatmap_multiple_files() {
1041 let events = vec![
1042 create_test_event_with_hash("abc1234"),
1043 create_test_event_with_hash("def5678"),
1044 create_test_event_with_hash("ghi9012"),
1045 ];
1046 let refs: Vec<&GitEvent> = events.iter().collect();
1047 let heatmap = calculate_file_heatmap(&refs, |hash| match hash {
1048 "abc1234" => Some(vec!["src/a.rs".to_string(), "src/b.rs".to_string()]),
1049 "def5678" => Some(vec!["src/a.rs".to_string()]),
1050 "ghi9012" => Some(vec!["src/a.rs".to_string(), "src/c.rs".to_string()]),
1051 _ => None,
1052 });
1053
1054 assert_eq!(heatmap.file_count(), 3);
1055 assert_eq!(heatmap.files[0].path, "src/a.rs");
1057 assert_eq!(heatmap.files[0].change_count, 3);
1058 }
1059
1060 #[test]
1061 fn test_file_heatmap_entry_heat_level() {
1062 let entry = FileHeatmapEntry {
1063 path: "test.rs".to_string(),
1064 change_count: 5,
1065 max_changes: 10,
1066 };
1067 assert!((entry.heat_level() - 0.5).abs() < 0.01);
1068 }
1069
1070 #[test]
1071 fn test_file_heatmap_entry_heat_bar() {
1072 let entry_high = FileHeatmapEntry {
1073 path: "test.rs".to_string(),
1074 change_count: 10,
1075 max_changes: 10,
1076 };
1077 assert_eq!(entry_high.heat_bar(), "█████");
1078
1079 let entry_low = FileHeatmapEntry {
1080 path: "test.rs".to_string(),
1081 change_count: 1,
1082 max_changes: 10,
1083 };
1084 assert_eq!(entry_low.heat_bar(), "█ ");
1085 }
1086
1087 #[test]
1090 fn test_aggregation_level_next() {
1091 assert_eq!(AggregationLevel::Files.next(), AggregationLevel::Shallow);
1092 assert_eq!(AggregationLevel::Shallow.next(), AggregationLevel::Deep);
1093 assert_eq!(AggregationLevel::Deep.next(), AggregationLevel::Files);
1094 }
1095
1096 #[test]
1097 fn test_aggregation_level_prev() {
1098 assert_eq!(AggregationLevel::Files.prev(), AggregationLevel::Deep);
1099 assert_eq!(AggregationLevel::Shallow.prev(), AggregationLevel::Files);
1100 assert_eq!(AggregationLevel::Deep.prev(), AggregationLevel::Shallow);
1101 }
1102
1103 #[test]
1104 fn test_aggregation_level_display_name() {
1105 assert_eq!(AggregationLevel::Files.display_name(), "Files");
1106 assert!(AggregationLevel::Shallow
1107 .display_name()
1108 .contains("2 levels"));
1109 assert!(AggregationLevel::Deep.display_name().contains("top level"));
1110 }
1111
1112 #[test]
1113 fn test_heatmap_with_aggregation_shallow() {
1114 let heatmap = FileHeatmap {
1115 files: vec![
1116 FileHeatmapEntry {
1117 path: "src/auth/login.rs".to_string(),
1118 change_count: 10,
1119 max_changes: 10,
1120 },
1121 FileHeatmapEntry {
1122 path: "src/auth/token.rs".to_string(),
1123 change_count: 5,
1124 max_changes: 10,
1125 },
1126 FileHeatmapEntry {
1127 path: "src/api/user.rs".to_string(),
1128 change_count: 3,
1129 max_changes: 10,
1130 },
1131 ],
1132 total_files: 3,
1133 aggregation_level: AggregationLevel::Files,
1134 };
1135
1136 let aggregated = heatmap.with_aggregation(AggregationLevel::Shallow);
1137 assert_eq!(aggregated.aggregation_level, AggregationLevel::Shallow);
1138 assert_eq!(aggregated.files.len(), 2); assert_eq!(aggregated.files[0].path, "src/auth/");
1142 assert_eq!(aggregated.files[0].change_count, 15);
1143 }
1144
1145 #[test]
1146 fn test_heatmap_with_aggregation_deep() {
1147 let heatmap = FileHeatmap {
1148 files: vec![
1149 FileHeatmapEntry {
1150 path: "src/auth/login.rs".to_string(),
1151 change_count: 10,
1152 max_changes: 10,
1153 },
1154 FileHeatmapEntry {
1155 path: "src/api/user.rs".to_string(),
1156 change_count: 5,
1157 max_changes: 10,
1158 },
1159 FileHeatmapEntry {
1160 path: "tests/test.rs".to_string(),
1161 change_count: 3,
1162 max_changes: 10,
1163 },
1164 ],
1165 total_files: 3,
1166 aggregation_level: AggregationLevel::Files,
1167 };
1168
1169 let aggregated = heatmap.with_aggregation(AggregationLevel::Deep);
1170 assert_eq!(aggregated.aggregation_level, AggregationLevel::Deep);
1171 assert_eq!(aggregated.files.len(), 2); assert_eq!(aggregated.files[0].path, "src/");
1175 assert_eq!(aggregated.files[0].change_count, 15);
1176 }
1177
1178 fn create_test_event_with_hash_changes(
1181 hash: &str,
1182 author: &str,
1183 insertions: usize,
1184 deletions: usize,
1185 ) -> GitEvent {
1186 GitEvent::commit(
1187 hash.to_string(),
1188 "test commit".to_string(),
1189 author.to_string(),
1190 Local::now(),
1191 insertions,
1192 deletions,
1193 )
1194 }
1195
1196 #[test]
1197 fn test_calculate_impact_scores_empty() {
1198 let events: Vec<&GitEvent> = vec![];
1199 let heatmap = FileHeatmap::default();
1200 let analysis = calculate_impact_scores(&events, |_| None, &heatmap);
1201 assert_eq!(analysis.total_commits, 0);
1202 assert_eq!(analysis.commits.len(), 0);
1203 assert_eq!(analysis.avg_score, 0.0);
1204 }
1205
1206 #[test]
1207 fn test_calculate_impact_scores_single_commit() {
1208 let events = vec![create_test_event_with_hash_changes(
1209 "abc1234", "Alice", 100, 50,
1210 )];
1211 let refs: Vec<&GitEvent> = events.iter().collect();
1212 let heatmap = FileHeatmap::default();
1213
1214 let analysis =
1215 calculate_impact_scores(&refs, |_| Some(vec!["src/main.rs".to_string()]), &heatmap);
1216
1217 assert_eq!(analysis.total_commits, 1);
1218 assert_eq!(analysis.commits.len(), 1);
1219
1220 let commit = &analysis.commits[0];
1221 assert_eq!(commit.commit_hash, "abc1234");
1222 assert_eq!(commit.files_changed, 1);
1223 assert_eq!(commit.insertions, 100);
1224 assert_eq!(commit.deletions, 50);
1225 }
1226
1227 #[test]
1228 fn test_calculate_impact_scores_file_score() {
1229 let events = vec![create_test_event_with_hash_changes(
1231 "abc1234", "Alice", 10, 5,
1232 )];
1233 let refs: Vec<&GitEvent> = events.iter().collect();
1234 let heatmap = FileHeatmap::default();
1235
1236 let files: Vec<String> = (0..50).map(|i| format!("file{}.rs", i)).collect();
1238 let analysis = calculate_impact_scores(&refs, |_| Some(files.clone()), &heatmap);
1239
1240 let commit = &analysis.commits[0];
1241 assert!((commit.file_score - 0.4).abs() < 0.01);
1242 }
1243
1244 #[test]
1245 fn test_calculate_impact_scores_change_score() {
1246 let events = vec![create_test_event_with_hash_changes(
1248 "abc1234", "Alice", 250, 250,
1249 )];
1250 let refs: Vec<&GitEvent> = events.iter().collect();
1251 let heatmap = FileHeatmap::default();
1252
1253 let analysis =
1254 calculate_impact_scores(&refs, |_| Some(vec!["src/main.rs".to_string()]), &heatmap);
1255
1256 let commit = &analysis.commits[0];
1257 assert!((commit.change_score - 0.4).abs() < 0.01);
1258 }
1259
1260 #[test]
1261 fn test_calculate_impact_scores_sorted_by_score_desc() {
1262 let events = vec![
1263 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), ];
1267 let refs: Vec<&GitEvent> = events.iter().collect();
1268 let heatmap = FileHeatmap::default();
1269
1270 let analysis =
1271 calculate_impact_scores(&refs, |_| Some(vec!["src/main.rs".to_string()]), &heatmap);
1272
1273 assert_eq!(analysis.commits.len(), 3);
1274 assert_eq!(analysis.commits[0].commit_hash, "high");
1276 assert_eq!(analysis.commits[1].commit_hash, "medium");
1277 assert_eq!(analysis.commits[2].commit_hash, "low");
1278 }
1279
1280 #[test]
1281 fn test_commit_impact_score_color_high() {
1282 let commit = CommitImpactScore {
1283 commit_hash: "abc".to_string(),
1284 commit_message: "test".to_string(),
1285 author: "Alice".to_string(),
1286 date: Local::now(),
1287 files_changed: 0,
1288 insertions: 0,
1289 deletions: 0,
1290 score: 0.8,
1291 file_score: 0.0,
1292 change_score: 0.0,
1293 heat_score: 0.0,
1294 };
1295 assert_eq!(commit.score_color(), "red");
1296 }
1297
1298 #[test]
1299 fn test_commit_impact_score_color_medium() {
1300 let commit = CommitImpactScore {
1301 commit_hash: "abc".to_string(),
1302 commit_message: "test".to_string(),
1303 author: "Alice".to_string(),
1304 date: Local::now(),
1305 files_changed: 0,
1306 insertions: 0,
1307 deletions: 0,
1308 score: 0.5,
1309 file_score: 0.0,
1310 change_score: 0.0,
1311 heat_score: 0.0,
1312 };
1313 assert_eq!(commit.score_color(), "yellow");
1314 }
1315
1316 #[test]
1317 fn test_commit_impact_score_color_low() {
1318 let commit = CommitImpactScore {
1319 commit_hash: "abc".to_string(),
1320 commit_message: "test".to_string(),
1321 author: "Alice".to_string(),
1322 date: Local::now(),
1323 files_changed: 0,
1324 insertions: 0,
1325 deletions: 0,
1326 score: 0.2,
1327 file_score: 0.0,
1328 change_score: 0.0,
1329 heat_score: 0.0,
1330 };
1331 assert_eq!(commit.score_color(), "green");
1332 }
1333
1334 #[test]
1335 fn test_commit_impact_score_bar() {
1336 let mut commit = CommitImpactScore {
1337 commit_hash: "abc".to_string(),
1338 commit_message: "test".to_string(),
1339 author: "Alice".to_string(),
1340 date: Local::now(),
1341 files_changed: 0,
1342 insertions: 0,
1343 deletions: 0,
1344 score: 0.0,
1345 file_score: 0.0,
1346 change_score: 0.0,
1347 heat_score: 0.0,
1348 };
1349
1350 commit.score = 0.9;
1351 assert_eq!(commit.score_bar(), "█████");
1352
1353 commit.score = 0.7;
1354 assert_eq!(commit.score_bar(), "████ ");
1355
1356 commit.score = 0.5;
1357 assert_eq!(commit.score_bar(), "███ ");
1358
1359 commit.score = 0.3;
1360 assert_eq!(commit.score_bar(), "██ ");
1361
1362 commit.score = 0.1;
1363 assert_eq!(commit.score_bar(), "█ ");
1364 }
1365
1366 #[test]
1367 fn test_calculate_impact_scores_high_impact_count() {
1368 let events = vec![
1369 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), ];
1373 let refs: Vec<&GitEvent> = events.iter().collect();
1374 let heatmap = FileHeatmap::default();
1375
1376 let analysis = calculate_impact_scores(
1378 &refs,
1379 |hash| {
1380 if hash == "a" {
1381 Some((0..50).map(|i| format!("file{}.rs", i)).collect())
1382 } else {
1383 Some(vec!["file.rs".to_string()])
1384 }
1385 },
1386 &heatmap,
1387 );
1388
1389 assert!(analysis.high_impact_count >= 1);
1390 }
1391
1392 #[test]
1395 fn test_calculate_change_coupling_empty() {
1396 let events: Vec<&GitEvent> = vec![];
1397 let analysis = calculate_change_coupling(&events, |_| None, 5, 0.3);
1398 assert_eq!(analysis.coupling_count(), 0);
1399 assert_eq!(analysis.high_coupling_count, 0);
1400 assert_eq!(analysis.total_files_analyzed, 0);
1401 }
1402
1403 #[test]
1404 fn test_calculate_change_coupling_single_file() {
1405 let events = vec![
1407 create_test_event_with_hash("commit1"),
1408 create_test_event_with_hash("commit2"),
1409 create_test_event_with_hash("commit3"),
1410 create_test_event_with_hash("commit4"),
1411 create_test_event_with_hash("commit5"),
1412 ];
1413 let refs: Vec<&GitEvent> = events.iter().collect();
1414
1415 let analysis =
1416 calculate_change_coupling(&refs, |_| Some(vec!["src/main.rs".to_string()]), 1, 0.0);
1417 assert_eq!(analysis.coupling_count(), 0);
1418 }
1419
1420 #[test]
1421 fn test_calculate_change_coupling_pair() {
1422 let events = vec![
1424 create_test_event_with_hash("commit1"),
1425 create_test_event_with_hash("commit2"),
1426 create_test_event_with_hash("commit3"),
1427 create_test_event_with_hash("commit4"),
1428 create_test_event_with_hash("commit5"),
1429 ];
1430 let refs: Vec<&GitEvent> = events.iter().collect();
1431
1432 let analysis = calculate_change_coupling(
1433 &refs,
1434 |_| Some(vec!["src/app.rs".to_string(), "src/ui.rs".to_string()]),
1435 5,
1436 0.3,
1437 );
1438
1439 assert_eq!(analysis.coupling_count(), 2);
1441
1442 for coupling in &analysis.couplings {
1444 assert!((coupling.coupling_percent - 1.0).abs() < 0.01);
1445 assert_eq!(coupling.co_change_count, 5);
1446 assert_eq!(coupling.file_change_count, 5);
1447 }
1448 }
1449
1450 #[test]
1451 fn test_calculate_change_coupling_partial() {
1452 let events = vec![
1454 create_test_event_with_hash("commit1"),
1455 create_test_event_with_hash("commit2"),
1456 create_test_event_with_hash("commit3"),
1457 create_test_event_with_hash("commit4"),
1458 create_test_event_with_hash("commit5"),
1459 create_test_event_with_hash("commit6"),
1460 create_test_event_with_hash("commit7"),
1461 create_test_event_with_hash("commit8"),
1462 create_test_event_with_hash("commit9"),
1463 create_test_event_with_hash("commit10"),
1464 ];
1465 let refs: Vec<&GitEvent> = events.iter().collect();
1466
1467 let analysis = calculate_change_coupling(
1468 &refs,
1469 |hash| {
1470 if hash == "commit9" || hash == "commit10" {
1473 Some(vec!["src/app.rs".to_string()])
1474 } else {
1475 Some(vec!["src/app.rs".to_string(), "src/ui.rs".to_string()])
1476 }
1477 },
1478 5,
1479 0.3,
1480 );
1481
1482 let app_to_ui = analysis
1484 .couplings
1485 .iter()
1486 .find(|c| c.file == "src/app.rs" && c.coupled_file == "src/ui.rs");
1487 assert!(app_to_ui.is_some());
1488 let coupling = app_to_ui.unwrap();
1489 assert!((coupling.coupling_percent - 0.8).abs() < 0.01);
1490
1491 let ui_to_app = analysis
1493 .couplings
1494 .iter()
1495 .find(|c| c.file == "src/ui.rs" && c.coupled_file == "src/app.rs");
1496 assert!(ui_to_app.is_some());
1497 let coupling = ui_to_app.unwrap();
1498 assert!((coupling.coupling_percent - 1.0).abs() < 0.01);
1499 }
1500
1501 #[test]
1502 fn test_calculate_change_coupling_min_commits_filter() {
1503 let events = vec![
1504 create_test_event_with_hash("commit1"),
1505 create_test_event_with_hash("commit2"),
1506 create_test_event_with_hash("commit3"),
1507 ];
1508 let refs: Vec<&GitEvent> = events.iter().collect();
1509
1510 let analysis = calculate_change_coupling(
1512 &refs,
1513 |_| Some(vec!["src/app.rs".to_string(), "src/ui.rs".to_string()]),
1514 5,
1515 0.3,
1516 );
1517
1518 assert_eq!(analysis.coupling_count(), 0);
1519 }
1520
1521 #[test]
1522 fn test_calculate_change_coupling_min_coupling_filter() {
1523 let events = vec![
1524 create_test_event_with_hash("commit1"),
1525 create_test_event_with_hash("commit2"),
1526 create_test_event_with_hash("commit3"),
1527 create_test_event_with_hash("commit4"),
1528 create_test_event_with_hash("commit5"),
1529 ];
1530 let refs: Vec<&GitEvent> = events.iter().collect();
1531
1532 let analysis = calculate_change_coupling(
1533 &refs,
1534 |hash| {
1535 if hash == "commit1" {
1537 Some(vec!["src/app.rs".to_string(), "src/ui.rs".to_string()])
1538 } else {
1539 Some(vec!["src/app.rs".to_string()])
1540 }
1541 },
1542 1,
1543 0.3, );
1545
1546 let app_to_ui = analysis
1548 .couplings
1549 .iter()
1550 .find(|c| c.file == "src/app.rs" && c.coupled_file == "src/ui.rs");
1551 assert!(app_to_ui.is_none());
1552 }
1553
1554 #[test]
1555 fn test_calculate_change_coupling_high_coupling_count() {
1556 let events = vec![
1557 create_test_event_with_hash("commit1"),
1558 create_test_event_with_hash("commit2"),
1559 create_test_event_with_hash("commit3"),
1560 create_test_event_with_hash("commit4"),
1561 create_test_event_with_hash("commit5"),
1562 ];
1563 let refs: Vec<&GitEvent> = events.iter().collect();
1564
1565 let analysis = calculate_change_coupling(
1567 &refs,
1568 |_| Some(vec!["src/app.rs".to_string(), "src/ui.rs".to_string()]),
1569 5,
1570 0.3,
1571 );
1572
1573 assert_eq!(analysis.high_coupling_count, 1);
1575 }
1576
1577 #[test]
1578 fn test_file_coupling_bar() {
1579 let coupling = FileCoupling {
1580 file: "src/app.rs".to_string(),
1581 coupled_file: "src/ui.rs".to_string(),
1582 co_change_count: 8,
1583 file_change_count: 10,
1584 coupling_percent: 0.8,
1585 };
1586
1587 assert_eq!(coupling.coupling_bar(), "[████████░░]");
1589 }
1590
1591 #[test]
1592 fn test_change_coupling_grouped_by_file() {
1593 let analysis = ChangeCouplingAnalysis {
1594 couplings: vec![
1595 FileCoupling {
1596 file: "src/app.rs".to_string(),
1597 coupled_file: "src/ui.rs".to_string(),
1598 co_change_count: 8,
1599 file_change_count: 10,
1600 coupling_percent: 0.8,
1601 },
1602 FileCoupling {
1603 file: "src/app.rs".to_string(),
1604 coupled_file: "src/main.rs".to_string(),
1605 co_change_count: 5,
1606 file_change_count: 10,
1607 coupling_percent: 0.5,
1608 },
1609 FileCoupling {
1610 file: "src/git.rs".to_string(),
1611 coupled_file: "src/filter.rs".to_string(),
1612 co_change_count: 3,
1613 file_change_count: 5,
1614 coupling_percent: 0.6,
1615 },
1616 ],
1617 high_coupling_count: 1,
1618 total_files_analyzed: 5,
1619 };
1620
1621 let grouped = analysis.grouped_by_file();
1622 assert_eq!(grouped.len(), 2);
1623
1624 assert_eq!(grouped[0].0, "src/app.rs");
1626 assert_eq!(grouped[0].1.len(), 2);
1627
1628 assert_eq!(grouped[1].0, "src/git.rs");
1630 assert_eq!(grouped[1].1.len(), 1);
1631 }
1632}