use crate::config::HealthThresholds;
use crate::metrics::{author_line_counts, MetricValue, RawValue};
use crate::snapshot::{BlameLine, RepoSnapshot};
fn is_file_author_dominated(lines: &[BlameLine]) -> bool {
if lines.is_empty() {
return false;
}
let author_lines = author_line_counts(lines);
let total: usize = author_lines.values().sum();
let max: usize = author_lines.values().copied().max().unwrap_or(0);
max * 2 > total
}
pub(super) fn bus_factor(snapshot: &RepoSnapshot, _thresholds: &HealthThresholds) -> MetricValue {
if snapshot.authors.len() <= 1 {
return MetricValue {
name: "Bus factor".to_string(),
description: "Solo project — not applicable".to_string(),
raw_value: RawValue::Text("N/A".to_string()),
score: None,
};
}
if snapshot.blame_map.is_empty() {
return MetricValue {
name: "Bus factor".to_string(),
description: "No blame data available".to_string(),
raw_value: RawValue::Text("N/A".to_string()),
score: None,
};
}
let total_files = snapshot.blame_map.len();
let dominated = snapshot
.blame_map
.values()
.filter(|lines| is_file_author_dominated(lines))
.count();
let pct = (dominated as f64 / total_files as f64) * 100.0;
let score = if pct < 10.0 {
100
} else if pct < 25.0 {
75
} else if pct < 50.0 {
50
} else {
25
};
MetricValue {
name: "Bus factor".to_string(),
description: format!("{:.0}% of files single-author dominated", pct),
raw_value: RawValue::Percentage(pct),
score: Some(score),
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::*;
use crate::metrics::testutil::{make_snapshot, two_authors};
use crate::snapshot::*;
use chrono::Utc;
#[test]
fn dominated_empty_slice_is_false() {
assert!(!is_file_author_dominated(&[]));
}
#[test]
fn dominated_single_author_all_lines_is_true() {
let now = Utc::now();
let lines: Vec<BlameLine> = (0..10).map(|_| BlameLine::new(0, now)).collect();
assert!(is_file_author_dominated(&lines));
}
#[test]
fn dominated_exact_50_50_split_is_false() {
let now = Utc::now();
let lines: Vec<BlameLine> = (0..100)
.map(|i| BlameLine::new(if i < 50 { 0 } else { 1 }, now))
.collect();
assert!(!is_file_author_dominated(&lines));
}
#[test]
fn dominated_51_49_split_is_true() {
let now = Utc::now();
let lines: Vec<BlameLine> = (0..100)
.map(|i| BlameLine::new(if i < 51 { 0 } else { 1 }, now))
.collect();
assert!(is_file_author_dominated(&lines));
}
#[test]
fn dominated_80_20_split_is_true() {
let now = Utc::now();
let lines: Vec<BlameLine> = (0..100)
.map(|i| BlameLine::new(if i < 80 { 0 } else { 1 }, now))
.collect();
assert!(is_file_author_dominated(&lines));
}
fn make_snapshot_with_blame() -> RepoSnapshot {
let mut snapshot = make_snapshot();
snapshot.authors = two_authors();
let now = Utc::now();
let mut blame_file1 = Vec::new();
for _ in 0..80 {
blame_file1.push(BlameLine::new(0, now));
}
for _ in 0..20 {
blame_file1.push(BlameLine::new(1, now));
}
snapshot
.blame_map
.insert(PathBuf::from("file1.rs"), blame_file1);
snapshot
}
#[test]
fn bus_factor_solo_project_has_no_score() {
let mut snapshot = make_snapshot();
snapshot.authors = vec![Author {
id: 0,
name: "Alice".into(),
email: "alice@test.com".into(),
}];
let now = Utc::now();
let blame: Vec<BlameLine> = (0..100).map(|_| BlameLine::new(0, now)).collect();
snapshot.blame_map.insert(PathBuf::from("file.rs"), blame);
let result = bus_factor(&snapshot, &HealthThresholds::default());
assert_eq!(result.score, None);
assert!(result.description.contains("Solo project"));
}
#[test]
fn bus_factor_detects_single_author_dominance() {
let snapshot = make_snapshot_with_blame();
let result = bus_factor(&snapshot, &HealthThresholds::default());
assert_eq!(result.score, Some(25));
match result.raw_value {
RawValue::Percentage(p) => assert!((p - 100.0).abs() < 1.0),
_ => panic!("Expected Percentage"),
}
}
#[test]
fn bus_factor_scores_100_when_few_dominated() {
let mut snapshot = make_snapshot();
snapshot.authors = two_authors();
let now = Utc::now();
for i in 0..5 {
let lines: Vec<BlameLine> = (0..100)
.map(|j| BlameLine::new(if j < 50 { 0 } else { 1 }, now))
.collect();
snapshot
.blame_map
.insert(PathBuf::from(format!("f{}.rs", i)), lines);
}
let result = bus_factor(&snapshot, &HealthThresholds::default());
assert_eq!(result.score, Some(100));
match result.raw_value {
RawValue::Percentage(p) => assert!((p - 0.0).abs() < 1.0),
_ => panic!("Expected Percentage"),
}
}
#[test]
fn bus_factor_scores_75_when_some_dominated() {
let mut snapshot = make_snapshot();
snapshot.authors = two_authors();
let now = Utc::now();
let dominated: Vec<BlameLine> = (0..100)
.map(|j| BlameLine::new(if j < 80 { 0 } else { 1 }, now))
.collect();
snapshot
.blame_map
.insert(PathBuf::from("dominated.rs"), dominated);
for i in 0..4 {
let lines: Vec<BlameLine> = (0..100)
.map(|j| BlameLine::new(if j < 50 { 0 } else { 1 }, now))
.collect();
snapshot
.blame_map
.insert(PathBuf::from(format!("balanced{}.rs", i)), lines);
}
let result = bus_factor(&snapshot, &HealthThresholds::default());
assert_eq!(result.score, Some(75));
}
#[test]
fn bus_factor_exact_50pct_not_dominated() {
let mut snapshot = make_snapshot();
snapshot.authors = two_authors();
let now = Utc::now();
let lines: Vec<BlameLine> = (0..100)
.map(|j| BlameLine::new(if j < 50 { 0 } else { 1 }, now)) .collect();
snapshot.blame_map.insert(PathBuf::from("file.rs"), lines);
let result = bus_factor(&snapshot, &HealthThresholds::default());
assert_eq!(result.score, Some(100));
match result.raw_value {
RawValue::Percentage(p) => assert!((p - 0.0).abs() < 1.0),
_ => panic!("Expected Percentage"),
}
}
#[test]
fn bus_factor_scores_75_at_exactly_10pct() {
let mut snapshot = make_snapshot();
snapshot.authors = two_authors();
let now = Utc::now();
let dominated: Vec<BlameLine> = (0..100)
.map(|j| BlameLine::new(if j < 80 { 0 } else { 1 }, now))
.collect();
snapshot
.blame_map
.insert(PathBuf::from("dominated.rs"), dominated);
for i in 0..9 {
let lines: Vec<BlameLine> = (0..100)
.map(|j| BlameLine::new(if j < 50 { 0 } else { 1 }, now))
.collect();
snapshot
.blame_map
.insert(PathBuf::from(format!("balanced{}.rs", i)), lines);
}
let result = bus_factor(&snapshot, &HealthThresholds::default());
assert_eq!(result.score, Some(75)); }
#[test]
fn bus_factor_scores_50_at_exactly_25pct() {
let mut snapshot = make_snapshot();
snapshot.authors = two_authors();
let now = Utc::now();
for i in 0..5 {
let lines: Vec<BlameLine> = (0..100)
.map(|j| BlameLine::new(if j < 80 { 0 } else { 1 }, now))
.collect();
snapshot
.blame_map
.insert(PathBuf::from(format!("dom{}.rs", i)), lines);
}
for i in 0..15 {
let lines: Vec<BlameLine> = (0..100)
.map(|j| BlameLine::new(if j < 50 { 0 } else { 1 }, now))
.collect();
snapshot
.blame_map
.insert(PathBuf::from(format!("bal{}.rs", i)), lines);
}
let result = bus_factor(&snapshot, &HealthThresholds::default());
assert_eq!(result.score, Some(50)); }
#[test]
fn bus_factor_scores_25_at_exactly_50pct() {
let mut snapshot = make_snapshot();
snapshot.authors = two_authors();
let now = Utc::now();
for i in 0..2 {
let lines: Vec<BlameLine> = (0..100)
.map(|j| BlameLine::new(if j < 80 { 0 } else { 1 }, now))
.collect();
snapshot
.blame_map
.insert(PathBuf::from(format!("dom{}.rs", i)), lines);
}
for i in 0..2 {
let lines: Vec<BlameLine> = (0..100)
.map(|j| BlameLine::new(if j < 50 { 0 } else { 1 }, now))
.collect();
snapshot
.blame_map
.insert(PathBuf::from(format!("bal{}.rs", i)), lines);
}
let result = bus_factor(&snapshot, &HealthThresholds::default());
assert_eq!(result.score, Some(25)); }
}