1use chrono::DateTime;
4use std::collections::HashMap;
5
6use crate::event::GitEvent;
7
8use super::{
9 BusFactorAnalysis, BusFactorEntry, BusFactorRisk, CommitQualityAnalysis, ContributorInfo,
10 FileHeatmap, TechDebtAnalysis, TechDebtEntry, TechDebtLevel,
11};
12
13const DAYS_PER_YEAR: f64 = 365.0;
14
15const TOP_CONTRIBUTORS: usize = 5;
17
18const BUS_FACTOR_CUMULATIVE_THRESHOLD: f64 = 50.0;
20
21const TECH_DEBT_CHURN_WEIGHT: f64 = 0.5;
23const TECH_DEBT_COMPLEXITY_WEIGHT: f64 = 0.4;
24const TECH_DEBT_AGE_WEIGHT: f64 = 0.1;
25const TECH_DEBT_HIGH: f64 = 0.6;
26const TECH_DEBT_MEDIUM: f64 = 0.3;
27
28const LARGE_COMMIT_CHANGES: usize = 50;
30
31const WEIGHT_QUALITY: f64 = 0.20;
33const WEIGHT_TEST: f64 = 0.15;
34const WEIGHT_BUS_FACTOR: f64 = 0.20;
35const WEIGHT_TECH_DEBT: f64 = 0.20;
36const WEIGHT_CHURN: f64 = 0.15;
37const WEIGHT_CADENCE: f64 = 0.10;
38
39const TEST_MSG_WEIGHT: f64 = 0.3;
41const TEST_FILE_WEIGHT: f64 = 0.7;
42const LOW_TEST_THRESHOLD: f64 = 0.1;
43const RECENT_COMMITS_WINDOW: usize = 30;
44
45const BUS_FACTOR_CRITICAL: f64 = 0.3;
47const BUS_FACTOR_WARNING: f64 = 0.5;
48const SINGLE_AUTHOR_CONCENTRATION: u32 = 70;
49
50const HIGH_CHURN_MULTIPLIER: f64 = 2.0;
52const CHURN_WARNING_THRESHOLD: f64 = 0.5;
53
54const MIN_PERIOD_FOR_CADENCE: u64 = 14;
56const CV_STABLE: f64 = 0.5;
57const CV_UNSTABLE: f64 = 2.0;
58const CV_RANGE: f64 = 1.5; const CV_WEIGHT: f64 = 0.8;
60const CADENCE_SCORE_UNSTABLE: f64 = 0.2;
61
62const HIGH_CONF_COMMITS: usize = 100;
64const HIGH_CONF_AUTHORS: usize = 3;
65const HIGH_CONF_DAYS: u64 = 30;
66const MEDIUM_CONF_COMMITS: usize = 30;
67const MEDIUM_CONF_DAYS: u64 = 7;
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
71pub enum AlertSeverity {
72 Info,
74 Warning,
76 Critical,
78}
79
80impl AlertSeverity {
81 pub fn color(&self) -> &'static str {
83 match self {
84 AlertSeverity::Info => "blue",
85 AlertSeverity::Warning => "yellow",
86 AlertSeverity::Critical => "red",
87 }
88 }
89
90 pub fn icon(&self) -> &'static str {
92 match self {
93 AlertSeverity::Info => "ℹ",
94 AlertSeverity::Warning => "⚠",
95 AlertSeverity::Critical => "🔴",
96 }
97 }
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub enum HealthAlertKind {
103 LowCommitQuality,
105 LowTestCoverage,
107 HighBusFactorRisk,
109 ModerateBusFactorRisk,
111 HighTechDebt,
113 HighCodeChurn,
115 Other,
117}
118
119#[derive(Debug, Clone)]
121pub struct HealthAlert {
122 pub kind: HealthAlertKind,
124 pub severity: AlertSeverity,
126 pub message: String,
128 pub details: Option<String>,
130}
131
132impl HealthAlert {
133 pub fn new(kind: HealthAlertKind, severity: AlertSeverity, message: impl Into<String>) -> Self {
135 Self {
136 kind,
137 severity,
138 message: message.into(),
139 details: None,
140 }
141 }
142
143 pub fn with_details(
145 kind: HealthAlertKind,
146 severity: AlertSeverity,
147 message: impl Into<String>,
148 details: impl Into<String>,
149 ) -> Self {
150 Self {
151 kind,
152 severity,
153 message: message.into(),
154 details: Some(details.into()),
155 }
156 }
157}
158
159pub fn is_test_file(path: &str) -> bool {
167 let lower = path.to_lowercase();
168
169 lower.ends_with("_test.rs")
171 || lower.ends_with("_test.go")
172 || lower.ends_with(".test.ts")
173 || lower.ends_with(".test.tsx")
174 || lower.ends_with(".test.js")
175 || lower.ends_with(".test.jsx")
176 || lower.ends_with(".spec.ts")
177 || lower.ends_with(".spec.tsx")
178 || lower.ends_with(".spec.js")
179 || lower.ends_with(".spec.jsx")
180 || lower
182 .rsplit('/')
183 .next()
184 .map(|f| f.starts_with("test_") && f.ends_with(".py"))
185 .unwrap_or(false)
186 || lower.contains("/tests/")
188 || lower.contains("/__tests__/")
189 || lower.contains("/spec/")
190 || lower.starts_with("tests/")
191 || lower.starts_with("__tests__/")
192 || lower.starts_with("spec/")
193}
194
195#[derive(Debug, Clone, Copy, PartialEq, Eq)]
197pub enum ConfidenceLevel {
198 High,
200 Medium,
202 Low,
204}
205
206impl ConfidenceLevel {
207 pub fn as_str(&self) -> &'static str {
209 match self {
210 ConfidenceLevel::High => "High",
211 ConfidenceLevel::Medium => "Medium",
212 ConfidenceLevel::Low => "Low",
213 }
214 }
215}
216
217#[derive(Debug, Clone)]
219pub struct HealthConfidence {
220 pub level: ConfidenceLevel,
222 pub reason: String,
224}
225
226#[derive(Debug, Clone, Default)]
228pub struct HealthScoreComponent {
229 pub score: f64,
231 pub weight: f64,
233 pub description: String,
235}
236
237#[derive(Debug, Clone)]
239pub struct ProjectHealth {
240 pub overall_score: u8,
242 pub quality: HealthScoreComponent,
244 pub test_health: HealthScoreComponent,
246 pub bus_factor_risk: HealthScoreComponent,
248 pub tech_debt: HealthScoreComponent,
250 pub code_churn: HealthScoreComponent,
252 pub commit_cadence: HealthScoreComponent,
254 pub alerts: Vec<HealthAlert>,
256 pub total_commits: usize,
258 pub total_authors: usize,
260 pub analysis_period_days: u64,
262 pub confidence: HealthConfidence,
264}
265
266impl Default for ProjectHealth {
267 fn default() -> Self {
268 Self {
269 overall_score: 50,
270 quality: HealthScoreComponent {
271 score: 0.5,
272 weight: WEIGHT_QUALITY,
273 description: "Commit quality average".to_string(),
274 },
275 test_health: HealthScoreComponent {
276 score: 0.5,
277 weight: WEIGHT_TEST,
278 description: "Test commit ratio".to_string(),
279 },
280 bus_factor_risk: HealthScoreComponent {
281 score: 0.5,
282 weight: WEIGHT_BUS_FACTOR,
283 description: "Knowledge concentration (lower is better)".to_string(),
284 },
285 tech_debt: HealthScoreComponent {
286 score: 0.5,
287 weight: WEIGHT_TECH_DEBT,
288 description: "Technical debt (lower is better)".to_string(),
289 },
290 code_churn: HealthScoreComponent {
291 score: 0.5,
292 weight: WEIGHT_CHURN,
293 description: "Code churn rate".to_string(),
294 },
295 commit_cadence: HealthScoreComponent {
296 score: 0.5,
297 weight: WEIGHT_CADENCE,
298 description: "Commit cadence stability".to_string(),
299 },
300 alerts: Vec::new(),
301 total_commits: 0,
302 total_authors: 0,
303 analysis_period_days: 0,
304 confidence: HealthConfidence {
305 level: ConfidenceLevel::Low,
306 reason: "No data".to_string(),
307 },
308 }
309 }
310}
311
312impl ProjectHealth {
313 pub fn level(&self) -> &'static str {
315 match self.overall_score {
316 90..=100 => "Excellent",
317 75..=89 => "Good",
318 60..=74 => "Fair",
319 45..=59 => "Needs Work",
320 30..=44 => "Poor",
321 _ => "Critical",
322 }
323 }
324
325 pub fn score_color(&self) -> &'static str {
327 match self.overall_score {
328 90..=100 => "green",
329 75..=89 => "teal",
330 60..=74 => "sapphire",
331 45..=59 => "yellow",
332 30..=44 => "peach",
333 _ => "red",
334 }
335 }
336
337 pub fn score_bar(&self) -> String {
339 let filled = (self.overall_score.min(100) / 10) as usize;
340 let empty = 10usize.saturating_sub(filled);
341 format!("{}{}", "█".repeat(filled), "░".repeat(empty))
342 }
343}
344
345pub fn calculate_bus_factor(
354 events: &[&GitEvent],
355 get_files: impl Fn(&str) -> Option<Vec<String>>,
356 min_commits: usize,
357) -> BusFactorAnalysis {
358 let mut dir_author_counts: HashMap<String, HashMap<String, usize>> = HashMap::new();
360
361 for event in events {
362 if let Some(files) = get_files(&event.short_hash) {
363 for file in &files {
364 let parts: Vec<&str> = file.split('/').collect();
366 if parts.len() > 1 {
367 let top_dir = parts[0].to_string();
368 let counts = dir_author_counts.entry(top_dir).or_default();
369 *counts.entry(event.author.clone()).or_insert(0) += 1;
370 }
371 if parts.len() > 2 {
373 let two_level_dir = format!("{}/{}", parts[0], parts[1]);
374 let counts = dir_author_counts.entry(two_level_dir).or_default();
375 *counts.entry(event.author.clone()).or_insert(0) += 1;
376 }
377 }
378 }
379 }
380
381 let mut entries = Vec::new();
382 let mut high_risk_count = 0;
383 let mut medium_risk_count = 0;
384
385 for (path, author_counts) in &dir_author_counts {
386 let total_commits: usize = author_counts.values().sum();
387
388 if total_commits < min_commits {
390 continue;
391 }
392
393 let mut contributors: Vec<ContributorInfo> = author_counts
395 .iter()
396 .map(|(name, &count)| ContributorInfo {
397 name: name.clone(),
398 commit_count: count,
399 contribution_percent: (count as f64 / total_commits as f64) * 100.0,
400 })
401 .collect();
402 contributors.sort_by(|a, b| b.commit_count.cmp(&a.commit_count));
403
404 let mut cumulative = 0.0;
406 let mut bus_factor = 0;
407 for contributor in &contributors {
408 cumulative += contributor.contribution_percent;
409 bus_factor += 1;
410 if cumulative >= BUS_FACTOR_CUMULATIVE_THRESHOLD {
411 break;
412 }
413 }
414
415 let risk_level = match bus_factor {
416 1 => {
417 high_risk_count += 1;
418 BusFactorRisk::High
419 }
420 2 => {
421 medium_risk_count += 1;
422 BusFactorRisk::Medium
423 }
424 _ => BusFactorRisk::Low,
425 };
426
427 entries.push(BusFactorEntry {
428 path: path.clone(),
429 bus_factor,
430 contributors: contributors.into_iter().take(TOP_CONTRIBUTORS).collect(),
431 total_commits,
432 risk_level,
433 is_directory: true,
434 });
435 }
436
437 entries.sort_by(|a, b| match (&a.risk_level, &b.risk_level) {
439 (BusFactorRisk::High, BusFactorRisk::High)
440 | (BusFactorRisk::Medium, BusFactorRisk::Medium)
441 | (BusFactorRisk::Low, BusFactorRisk::Low) => b.total_commits.cmp(&a.total_commits),
442 (BusFactorRisk::High, _) => std::cmp::Ordering::Less,
443 (_, BusFactorRisk::High) => std::cmp::Ordering::Greater,
444 (BusFactorRisk::Medium, BusFactorRisk::Low) => std::cmp::Ordering::Less,
445 (BusFactorRisk::Low, BusFactorRisk::Medium) => std::cmp::Ordering::Greater,
446 });
447
448 BusFactorAnalysis {
449 total_paths_analyzed: entries.len(),
450 entries,
451 high_risk_count,
452 medium_risk_count,
453 }
454}
455
456pub fn calculate_tech_debt(
465 events: &[&GitEvent],
466 get_files: impl Fn(&str) -> Option<Vec<String>>,
467 min_commits: usize,
468) -> TechDebtAnalysis {
469 use chrono::Local;
470
471 let mut file_stats: HashMap<String, (usize, usize, DateTime<Local>)> = HashMap::new();
473
474 for event in events {
475 if let Some(files) = get_files(&event.short_hash) {
476 let changes_per_file = (event.files_added + event.files_deleted) / files.len().max(1);
477 for file in files {
478 let entry = file_stats.entry(file).or_insert((0, 0, event.timestamp));
479 entry.0 += 1; entry.1 += changes_per_file; if event.timestamp > entry.2 {
483 entry.2 = event.timestamp;
484 }
485 }
486 }
487 }
488
489 let max_changes = file_stats.values().map(|(c, _, _)| *c).max().unwrap_or(1);
491 let max_lines = file_stats.values().map(|(_, l, _)| *l).max().unwrap_or(1);
492 let now = Local::now();
493
494 let mut entries = Vec::new();
495 let mut total_score = 0.0;
496 let mut high_debt_count = 0;
497
498 for (path, (change_count, total_changes, last_modified)) in &file_stats {
499 if *change_count < min_commits {
500 continue;
501 }
502
503 let churn_score = *change_count as f64 / max_changes as f64;
505 let complexity_score = *total_changes as f64 / max_lines as f64;
506
507 let days_since_change = (now - *last_modified).num_days().max(0) as f64;
509 let age_score = (days_since_change / DAYS_PER_YEAR).min(1.0);
510
511 let score = churn_score * TECH_DEBT_CHURN_WEIGHT
515 + complexity_score * TECH_DEBT_COMPLEXITY_WEIGHT
516 + (churn_score * age_score) * TECH_DEBT_AGE_WEIGHT;
517
518 let debt_level = if score >= TECH_DEBT_HIGH {
519 high_debt_count += 1;
520 TechDebtLevel::High
521 } else if score >= TECH_DEBT_MEDIUM {
522 TechDebtLevel::Medium
523 } else {
524 TechDebtLevel::Low
525 };
526
527 entries.push(TechDebtEntry {
528 path: path.clone(),
529 score,
530 churn_score,
531 complexity_score,
532 age_score,
533 change_count: *change_count,
534 total_changes: *total_changes,
535 debt_level,
536 });
537
538 total_score += score;
539 }
540
541 entries.sort_by(|a, b| {
543 b.score
544 .partial_cmp(&a.score)
545 .unwrap_or(std::cmp::Ordering::Equal)
546 });
547
548 let total_files_analyzed = entries.len();
549 let avg_score = if total_files_analyzed > 0 {
550 total_score / total_files_analyzed as f64
551 } else {
552 0.0
553 };
554
555 TechDebtAnalysis {
556 entries,
557 avg_score,
558 high_debt_count,
559 total_files_analyzed,
560 }
561}
562
563pub fn calculate_project_health<F>(
573 events: &[&GitEvent],
574 files_fn: F,
575 quality_analysis: Option<&CommitQualityAnalysis>,
576 bus_factor: Option<&BusFactorAnalysis>,
577 tech_debt_analysis: Option<&TechDebtAnalysis>,
578 heatmap: &FileHeatmap,
579) -> ProjectHealth
580where
581 F: Fn(&str) -> Option<Vec<String>>,
582{
583 if events.is_empty() {
584 return ProjectHealth::default();
585 }
586
587 let total_commits = events.len();
588 let mut alerts = Vec::new();
589
590 let authors: std::collections::HashSet<&str> =
592 events.iter().map(|e| e.author.as_str()).collect();
593 let total_authors = authors.len();
594
595 let analysis_period_days = if events.len() >= 2 {
597 let newest = events.first().map(|e| e.timestamp);
598 let oldest = events.last().map(|e| e.timestamp);
599 if let (Some(n), Some(o)) = (newest, oldest) {
600 let duration = n.signed_duration_since(o);
601 duration.num_days().unsigned_abs()
602 } else {
603 0
604 }
605 } else {
606 0
607 };
608
609 let quality_score = if let Some(qa) = quality_analysis {
611 qa.avg_score
612 } else {
613 let conventional_count = events
615 .iter()
616 .filter(|e| {
617 let msg = e.message.to_lowercase();
618 msg.starts_with("feat:")
619 || msg.starts_with("fix:")
620 || msg.starts_with("docs:")
621 || msg.starts_with("style:")
622 || msg.starts_with("refactor:")
623 || msg.starts_with("test:")
624 || msg.starts_with("chore:")
625 || msg.starts_with("perf:")
626 })
627 .count();
628 conventional_count as f64 / total_commits as f64
629 };
630
631 let conventional_pct = (quality_score * 100.0).round() as u32;
632
633 let quality = HealthScoreComponent {
634 score: quality_score,
635 weight: WEIGHT_QUALITY,
636 description: format!(
637 "Commit quality: {:.0}% conventional commits",
638 quality_score * 100.0
639 ),
640 };
641
642 if quality_score < BUS_FACTOR_CRITICAL {
643 alerts.push(HealthAlert::with_details(
644 HealthAlertKind::LowCommitQuality,
645 AlertSeverity::Warning,
646 "Low commit quality",
647 format!(
648 "Conventional Commit format is {}% of all commits",
649 conventional_pct
650 ),
651 ));
652 }
653
654 let test_msg_count = events
656 .iter()
657 .filter(|e| e.message.to_lowercase().starts_with("test:"))
658 .count();
659 let test_file_commit_count = events
660 .iter()
661 .filter(|e| {
662 if let Some(files) = files_fn(&e.short_hash) {
663 files.iter().any(|f| is_test_file(f))
664 } else {
665 false
666 }
667 })
668 .count();
669 let test_score = ((test_msg_count as f64 * TEST_MSG_WEIGHT
671 + test_file_commit_count as f64 * TEST_FILE_WEIGHT)
672 / total_commits as f64)
673 .min(1.0);
674
675 let test_health = HealthScoreComponent {
676 score: test_score,
677 weight: WEIGHT_TEST,
678 description: format!(
679 "Test coverage: {:.0}% test-related commits",
680 test_score * 100.0
681 ),
682 };
683
684 if test_score < LOW_TEST_THRESHOLD {
685 let recent_count = events.len().min(RECENT_COMMITS_WINDOW);
686 let recent_test_files = events
687 .iter()
688 .take(recent_count)
689 .filter(|e| {
690 if let Some(files) = files_fn(&e.short_hash) {
691 files.iter().any(|f| is_test_file(f))
692 } else {
693 false
694 }
695 })
696 .count();
697 alerts.push(HealthAlert::with_details(
698 HealthAlertKind::LowTestCoverage,
699 AlertSeverity::Info,
700 "Low test coverage in commits",
701 format!(
702 "In the last {} commits, only {} include test file changes",
703 recent_count, recent_test_files
704 ),
705 ));
706 }
707
708 let bus_factor_score;
710 let mut top_author_info = String::new();
711 if let Some(bf) = bus_factor {
712 let low_risk_count = bf
714 .total_paths_analyzed
715 .saturating_sub(bf.high_risk_count + bf.medium_risk_count);
716 let risk_ratio = bf.high_risk_count as f64
717 / (bf.high_risk_count + bf.medium_risk_count + low_risk_count).max(1) as f64;
718 bus_factor_score = 1.0 - risk_ratio;
719 } else {
720 let mut author_commits: HashMap<&str, usize> = HashMap::new();
722 for e in events.iter() {
723 *author_commits.entry(e.author.as_str()).or_insert(0) += 1;
724 }
725 let total = total_commits as f64;
726 let entropy: f64 = author_commits
727 .values()
728 .map(|&count| {
729 let p = count as f64 / total;
730 if p > 0.0 {
731 -p * p.ln()
732 } else {
733 0.0
734 }
735 })
736 .sum();
737 let max_entropy = (total_authors as f64).ln();
738 bus_factor_score = if max_entropy > 0.0 {
739 entropy / max_entropy
740 } else {
741 0.0
742 };
743
744 if let Some((&top_author, &top_count)) = author_commits.iter().max_by_key(|(_, &c)| c) {
746 let pct = (top_count as f64 / total * 100.0).round() as u32;
747 if pct > SINGLE_AUTHOR_CONCENTRATION {
748 top_author_info = format!(
749 "{}% of commits are by a single author ({})",
750 pct, top_author
751 );
752 }
753 }
754 }
755
756 let bus_factor_risk = if total_authors <= 1 {
757 HealthScoreComponent {
758 score: 0.0,
759 weight: 0.0,
760 description: "Skipped: solo development".to_string(),
761 }
762 } else {
763 HealthScoreComponent {
764 score: bus_factor_score,
765 weight: WEIGHT_BUS_FACTOR,
766 description: format!(
767 "Knowledge distribution: {:.0}% (higher is better)",
768 bus_factor_score * 100.0
769 ),
770 }
771 };
772
773 if total_authors > 1 {
774 if bus_factor_score < BUS_FACTOR_CRITICAL {
775 let details = if top_author_info.is_empty() {
776 "Knowledge is concentrated in few contributors. Consider knowledge sharing."
777 .to_string()
778 } else {
779 top_author_info.clone()
780 };
781 alerts.push(HealthAlert::with_details(
782 HealthAlertKind::HighBusFactorRisk,
783 AlertSeverity::Critical,
784 "High bus factor risk",
785 details,
786 ));
787 } else if bus_factor_score < BUS_FACTOR_WARNING {
788 let details = if top_author_info.is_empty() {
789 "Consider improving knowledge distribution across team members.".to_string()
790 } else {
791 top_author_info
792 };
793 alerts.push(HealthAlert::with_details(
794 HealthAlertKind::ModerateBusFactorRisk,
795 AlertSeverity::Warning,
796 "Moderate bus factor risk",
797 details,
798 ));
799 }
800 }
801
802 let tech_debt_score = if let Some(td) = tech_debt_analysis {
804 1.0 - td.avg_score.min(1.0)
805 } else {
806 let large_commits = events
808 .iter()
809 .filter(|e| e.files_added + e.files_deleted > LARGE_COMMIT_CHANGES)
810 .count();
811 let large_ratio = large_commits as f64 / total_commits as f64;
812 1.0 - large_ratio.min(1.0)
813 };
814
815 let tech_debt = HealthScoreComponent {
816 score: tech_debt_score,
817 weight: WEIGHT_TECH_DEBT,
818 description: format!(
819 "Technical debt: {:.0}% clean (higher is better)",
820 tech_debt_score * 100.0
821 ),
822 };
823
824 if tech_debt_score < TECH_DEBT_MEDIUM {
825 alerts.push(HealthAlert::with_details(
826 HealthAlertKind::HighTechDebt,
827 AlertSeverity::Warning,
828 "High technical debt indicated",
829 "Many large commits suggest accumulated technical debt.",
830 ));
831 }
832
833 let churn_score = if heatmap.total_files > 0 {
835 let avg_changes = heatmap.files.iter().map(|f| f.change_count).sum::<usize>() as f64
837 / heatmap.total_files as f64;
838 let high_churn_count = heatmap
839 .files
840 .iter()
841 .filter(|f| f.change_count as f64 > avg_changes * HIGH_CHURN_MULTIPLIER)
842 .count();
843 let churn_ratio = high_churn_count as f64 / heatmap.total_files as f64;
844 (1.0 - churn_ratio).max(0.0)
845 } else {
846 0.5 };
848
849 let code_churn = HealthScoreComponent {
850 score: churn_score,
851 weight: WEIGHT_CHURN,
852 description: format!(
853 "Code churn: {:.0}% stable (higher is better)",
854 churn_score * 100.0
855 ),
856 };
857
858 if churn_score < CHURN_WARNING_THRESHOLD {
859 if let Some(top_file) = heatmap.files.first() {
861 alerts.push(HealthAlert::with_details(
862 HealthAlertKind::HighCodeChurn,
863 AlertSeverity::Warning,
864 "High code churn detected",
865 format!(
866 "{} has been changed {} times in the analysis period",
867 top_file.path, top_file.change_count
868 ),
869 ));
870 }
871 }
872
873 let cadence_score = if analysis_period_days >= MIN_PERIOD_FOR_CADENCE {
875 let mut weekly_counts: HashMap<i64, usize> = HashMap::new();
877 if let Some(oldest_ts) = events.last().map(|e| e.timestamp) {
878 for e in events.iter() {
879 let days_since = e
880 .timestamp
881 .signed_duration_since(oldest_ts)
882 .num_days()
883 .unsigned_abs();
884 let week = (days_since / 7) as i64; *weekly_counts.entry(week).or_insert(0) += 1;
886 }
887 }
888
889 if weekly_counts.len() >= 2 {
890 let values: Vec<f64> = weekly_counts.values().map(|&v| v as f64).collect();
891 let mean = values.iter().sum::<f64>() / values.len() as f64;
892 if mean > 0.0 {
893 let variance =
894 values.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / values.len() as f64;
895 let std_dev = variance.sqrt();
896 let cv = std_dev / mean; if cv < CV_STABLE {
900 1.0
901 } else if cv > CV_UNSTABLE {
902 CADENCE_SCORE_UNSTABLE
903 } else {
904 1.0 - (cv - CV_STABLE) / CV_RANGE * CV_WEIGHT
906 }
907 } else {
908 0.5
909 }
910 } else {
911 0.5
912 }
913 } else {
914 0.5 };
916
917 let commit_cadence = HealthScoreComponent {
918 score: cadence_score,
919 weight: WEIGHT_CADENCE,
920 description: format!(
921 "Commit cadence: {:.0}% stable (higher is better)",
922 cadence_score * 100.0
923 ),
924 };
925
926 let total_weight = quality.weight
928 + test_health.weight
929 + bus_factor_risk.weight
930 + tech_debt.weight
931 + code_churn.weight
932 + commit_cadence.weight;
933 let raw = quality.score * quality.weight
934 + test_health.score * test_health.weight
935 + bus_factor_risk.score * bus_factor_risk.weight
936 + tech_debt.score * tech_debt.weight
937 + code_churn.score * code_churn.weight
938 + commit_cadence.score * commit_cadence.weight;
939 let overall = if total_weight > 0.0 {
940 raw / total_weight
941 } else {
942 0.0
943 };
944 let overall_score = (overall * 100.0).round().min(100.0) as u8;
945
946 let confidence = if total_commits >= HIGH_CONF_COMMITS
948 && total_authors >= HIGH_CONF_AUTHORS
949 && analysis_period_days >= HIGH_CONF_DAYS
950 {
951 HealthConfidence {
952 level: ConfidenceLevel::High,
953 reason: format!(
954 "{} commits, {} authors, {} days",
955 total_commits, total_authors, analysis_period_days
956 ),
957 }
958 } else if total_commits >= MEDIUM_CONF_COMMITS && analysis_period_days >= MEDIUM_CONF_DAYS {
959 HealthConfidence {
960 level: ConfidenceLevel::Medium,
961 reason: format!(
962 "{} commits over {} days",
963 total_commits, analysis_period_days
964 ),
965 }
966 } else {
967 HealthConfidence {
968 level: ConfidenceLevel::Low,
969 reason: format!(
970 "Only {} commits, {} authors, {} days — results may not be representative",
971 total_commits, total_authors, analysis_period_days
972 ),
973 }
974 };
975
976 alerts.sort_by(|a, b| b.severity.cmp(&a.severity));
978
979 ProjectHealth {
980 overall_score,
981 quality,
982 test_health,
983 bus_factor_risk,
984 tech_debt,
985 code_churn,
986 commit_cadence,
987 alerts,
988 total_commits,
989 total_authors,
990 analysis_period_days,
991 confidence,
992 }
993}
994
995#[cfg(test)]
996#[allow(clippy::useless_vec)]
997mod tests {
998 use super::*;
999 use crate::stats::AggregationLevel;
1000 use chrono::Local;
1001
1002 fn create_test_event(author: &str, insertions: usize, deletions: usize) -> GitEvent {
1003 GitEvent::commit(
1004 "abc1234".to_string(),
1005 "test commit".to_string(),
1006 author.to_string(),
1007 Local::now(),
1008 insertions,
1009 deletions,
1010 )
1011 }
1012
1013 fn create_test_event_for_quality(
1014 hash: &str,
1015 message: &str,
1016 insertions: usize,
1017 deletions: usize,
1018 ) -> GitEvent {
1019 GitEvent::commit(
1020 hash.to_string(),
1021 message.to_string(),
1022 "author".to_string(),
1023 Local::now(),
1024 insertions,
1025 deletions,
1026 )
1027 }
1028
1029 #[test]
1032 fn test_project_health_default() {
1033 let health = ProjectHealth::default();
1034 assert_eq!(health.overall_score, 50);
1035 assert_eq!(health.level(), "Needs Work");
1036 }
1037
1038 #[test]
1039 fn test_project_health_level() {
1040 let health = ProjectHealth {
1041 overall_score: 95,
1042 ..Default::default()
1043 };
1044 assert_eq!(health.level(), "Excellent");
1045
1046 let health = ProjectHealth {
1047 overall_score: 80,
1048 ..Default::default()
1049 };
1050 assert_eq!(health.level(), "Good");
1051
1052 let health = ProjectHealth {
1053 overall_score: 65,
1054 ..Default::default()
1055 };
1056 assert_eq!(health.level(), "Fair");
1057
1058 let health = ProjectHealth {
1059 overall_score: 50,
1060 ..Default::default()
1061 };
1062 assert_eq!(health.level(), "Needs Work");
1063
1064 let health = ProjectHealth {
1065 overall_score: 35,
1066 ..Default::default()
1067 };
1068 assert_eq!(health.level(), "Poor");
1069
1070 let health = ProjectHealth {
1071 overall_score: 20,
1072 ..Default::default()
1073 };
1074 assert_eq!(health.level(), "Critical");
1075 }
1076
1077 #[test]
1078 fn test_project_health_score_bar() {
1079 let health = ProjectHealth {
1080 overall_score: 100,
1081 ..Default::default()
1082 };
1083 assert_eq!(health.score_bar(), "██████████");
1084
1085 let health = ProjectHealth {
1086 overall_score: 50,
1087 ..Default::default()
1088 };
1089 assert_eq!(health.score_bar(), "█████░░░░░");
1090
1091 let health = ProjectHealth {
1092 overall_score: 0,
1093 ..Default::default()
1094 };
1095 assert_eq!(health.score_bar(), "░░░░░░░░░░");
1096 }
1097
1098 #[test]
1099 fn test_alert_severity_ordering() {
1100 assert!(AlertSeverity::Critical > AlertSeverity::Warning);
1101 assert!(AlertSeverity::Warning > AlertSeverity::Info);
1102 }
1103
1104 #[test]
1105 fn test_calculate_project_health_empty() {
1106 let events: Vec<&GitEvent> = vec![];
1107 let empty_heatmap = FileHeatmap {
1108 files: vec![],
1109 total_files: 0,
1110 aggregation_level: AggregationLevel::Files,
1111 };
1112 let health = calculate_project_health(&events, |_| None, None, None, None, &empty_heatmap);
1113 assert_eq!(health.overall_score, 50);
1114 assert_eq!(health.total_commits, 0);
1115 }
1116
1117 #[test]
1118 fn test_calculate_project_health_with_events() {
1119 let events = vec![
1120 create_test_event_for_quality("hash1", "feat: add new feature", 50, 10),
1121 create_test_event_for_quality("hash2", "fix: bug fix", 20, 5),
1122 create_test_event_for_quality("hash3", "test: add tests", 30, 0),
1123 ];
1124 let refs: Vec<&GitEvent> = events.iter().collect();
1125 let empty_heatmap = FileHeatmap {
1126 files: vec![],
1127 total_files: 0,
1128 aggregation_level: AggregationLevel::Files,
1129 };
1130
1131 let health = calculate_project_health(&refs, |_| None, None, None, None, &empty_heatmap);
1132
1133 assert_eq!(health.total_commits, 3);
1134 assert!(health.overall_score > 0);
1135 assert!(health.quality.score > 0.5);
1137 }
1138
1139 #[test]
1140 fn test_calculate_project_health_alerts() {
1141 let mut events = Vec::new();
1143 for i in 0..10 {
1144 let mut event = create_test_event("single_author", 10, 5);
1145 event.short_hash = format!("hash{}", i);
1146 events.push(event);
1147 }
1148 let refs: Vec<&GitEvent> = events.iter().collect();
1149 let empty_heatmap = FileHeatmap {
1150 files: vec![],
1151 total_files: 0,
1152 aggregation_level: AggregationLevel::Files,
1153 };
1154
1155 let health = calculate_project_health(&refs, |_| None, None, None, None, &empty_heatmap);
1156
1157 let has_bus_factor_alert = health
1159 .alerts
1160 .iter()
1161 .any(|a| a.message.contains("bus factor"));
1162 assert!(!has_bus_factor_alert);
1163 assert_eq!(health.bus_factor_risk.weight, 0.0);
1165
1166 let mut multi_events = Vec::new();
1168 for i in 0..10 {
1169 let mut event = create_test_event("author_a", 10, 5);
1170 event.short_hash = format!("hash_a{}", i);
1171 multi_events.push(event);
1172 }
1173 let mut event_b = create_test_event("author_b", 1, 0);
1175 event_b.short_hash = "hash_b0".to_string();
1176 multi_events.push(event_b);
1177 let multi_refs: Vec<&GitEvent> = multi_events.iter().collect();
1178
1179 let health2 =
1180 calculate_project_health(&multi_refs, |_| None, None, None, None, &empty_heatmap);
1181 let has_bus_factor_alert2 = health2
1182 .alerts
1183 .iter()
1184 .any(|a| a.message.contains("bus factor"));
1185 assert!(has_bus_factor_alert2);
1186 assert_eq!(health2.bus_factor_risk.weight, 0.20);
1187 }
1188
1189 #[test]
1190 fn test_is_test_file() {
1191 assert!(is_test_file("src/foo_test.rs"));
1192 assert!(is_test_file("src/foo_test.go"));
1193 assert!(is_test_file("src/foo.test.ts"));
1194 assert!(is_test_file("src/foo.test.js"));
1195 assert!(is_test_file("src/foo.spec.ts"));
1196 assert!(is_test_file("src/foo.spec.js"));
1197 assert!(is_test_file("tests/test_main.py"));
1198 assert!(is_test_file("__tests__/foo.js"));
1199 assert!(is_test_file("spec/helper.rb"));
1200
1201 assert!(!is_test_file("src/contest.rs"));
1203 assert!(!is_test_file("src/latest.ts"));
1204 assert!(!is_test_file("src/main.rs"));
1205 assert!(!is_test_file("protest.js"));
1206 }
1207
1208 #[test]
1209 fn test_confidence_levels() {
1210 let events: Vec<GitEvent> = (0..100)
1211 .map(|i| {
1212 let mut e = create_test_event(
1213 if i % 3 == 0 {
1214 "Alice"
1215 } else if i % 3 == 1 {
1216 "Bob"
1217 } else {
1218 "Charlie"
1219 },
1220 10,
1221 5,
1222 );
1223 e.short_hash = format!("hash{}", i);
1224 e.timestamp = Local::now() - chrono::Duration::days(i as i64);
1225 e
1226 })
1227 .collect();
1228 let refs: Vec<&GitEvent> = events.iter().collect();
1229 let empty_heatmap = FileHeatmap {
1230 files: vec![],
1231 total_files: 0,
1232 aggregation_level: AggregationLevel::Files,
1233 };
1234 let health = calculate_project_health(&refs, |_| None, None, None, None, &empty_heatmap);
1235 assert_eq!(health.confidence.level, ConfidenceLevel::High);
1236 }
1237}