use std::collections::HashMap;
use std::path::{Path, PathBuf};
use serde::Serialize;
use super::identity::{AuthorHashKey, AuthorId};
pub const BUS_FACTOR_SCHEMA_VERSION: u32 = 2;
const DOA_INTERCEPT: f64 = 3.293;
const DOA_FA_WEIGHT: f64 = 1.098;
const DOA_DL_WEIGHT: f64 = 0.164;
const DOA_AC_WEIGHT: f64 = 0.321;
pub const DOA_NORMALIZED_THRESHOLD: f64 = 0.75;
pub const DEFAULT_COVERAGE_THRESHOLD: f64 = 0.5;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AuthorContribution {
pub author: AuthorId,
pub deliveries: u32,
pub first_authorship: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FileAuthorship {
pub path: PathBuf,
pub contributions: Vec<AuthorContribution>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
pub struct GroupBusFactor {
pub bus_factor: u32,
pub files: u32,
pub authors: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub key_author_ids: Option<Vec<String>>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct DirectoryBusFactor {
pub directory: String,
#[serde(flatten)]
pub group: GroupBusFactor,
}
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct BusFactor {
pub bus_factor_schema_version: u32,
pub coverage_threshold: f64,
pub doa_threshold: f64,
pub repo: GroupBusFactor,
pub by_directory: Vec<DirectoryBusFactor>,
}
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct VcsAggregate {
pub bus_factor: BusFactor,
}
#[must_use]
pub fn compute(
authorship: &[FileAuthorship],
coverage_threshold: f64,
emit_author_details: bool,
author_hash_key: Option<&AuthorHashKey>,
) -> BusFactor {
let coverage = clamp_threshold(coverage_threshold);
let files: Vec<&FileAuthorship> = authorship
.iter()
.filter(|f| !f.contributions.is_empty())
.collect();
let resolved: Vec<Vec<&AuthorId>> = files.iter().map(|f| authors_of_file(f)).collect();
let repo_files: Vec<&[&AuthorId]> = resolved.iter().map(Vec::as_slice).collect();
let repo = group_bus_factor(&repo_files, coverage, emit_author_details, author_hash_key);
let mut groups: HashMap<PathBuf, Vec<usize>> = HashMap::new();
for (idx, file) in files.iter().enumerate() {
for key in directory_keys(&file.path) {
groups.entry(key).or_default().push(idx);
}
}
let mut by_directory: Vec<DirectoryBusFactor> = groups
.into_iter()
.filter_map(|(dir, indices)| {
let directory = path_to_forward_slash(&dir)?;
let files: Vec<&[&AuthorId]> =
indices.iter().map(|&i| resolved[i].as_slice()).collect();
let group = group_bus_factor(&files, coverage, emit_author_details, author_hash_key);
Some(DirectoryBusFactor { directory, group })
})
.collect();
by_directory.sort_by(|a, b| a.directory.cmp(&b.directory));
BusFactor {
bus_factor_schema_version: BUS_FACTOR_SCHEMA_VERSION,
coverage_threshold: coverage,
doa_threshold: DOA_NORMALIZED_THRESHOLD,
repo,
by_directory,
}
}
fn doa(contribution: &AuthorContribution, accepted_changes: u32) -> f64 {
DOA_INTERCEPT
+ DOA_FA_WEIGHT * f64::from(contribution.first_authorship)
+ DOA_DL_WEIGHT * f64::from(contribution.deliveries)
- DOA_AC_WEIGHT * (1.0 + f64::from(accepted_changes)).ln()
}
struct GroupAuthor {
hashed: String,
authored_files: Vec<usize>,
}
fn group_bus_factor(
files: &[&[&AuthorId]],
coverage: f64,
emit: bool,
key: Option<&AuthorHashKey>,
) -> GroupBusFactor {
let total_files = files.len();
if total_files == 0 {
return GroupBusFactor::default();
}
let mut author_index: HashMap<&AuthorId, usize> = HashMap::new();
let mut authors: Vec<GroupAuthor> = Vec::new();
let mut remaining_authors = vec![0u32; total_files];
for (file_idx, file_authors) in files.iter().enumerate() {
for &author in *file_authors {
let idx = *author_index.entry(author).or_insert_with(|| {
authors.push(GroupAuthor {
hashed: author.hashed(),
authored_files: Vec::new(),
});
authors.len() - 1
});
authors[idx].authored_files.push(file_idx);
remaining_authors[file_idx] += 1;
}
}
let bus_factor = greedy_truck_factor(&authors, &mut remaining_authors, coverage, emit, key);
GroupBusFactor {
bus_factor: bus_factor.removed,
files: u32::try_from(total_files).unwrap_or(u32::MAX),
authors: u32::try_from(authors.len()).unwrap_or(u32::MAX),
key_author_ids: bus_factor.key_authors,
}
}
fn authors_of_file(file: &FileAuthorship) -> Vec<&AuthorId> {
let total_deliveries: u32 = file
.contributions
.iter()
.map(|c| c.deliveries)
.fold(0u32, u32::saturating_add);
let scored: Vec<(f64, &AuthorId)> = file
.contributions
.iter()
.map(|c| {
let accepted = total_deliveries.saturating_sub(c.deliveries);
(doa(c, accepted), &c.author)
})
.collect();
let max_doa = scored.iter().map(|&(d, _)| d).fold(f64::MIN, f64::max);
if max_doa <= 0.0 {
return scored
.iter()
.min_by(|a, b| {
b.0.total_cmp(&a.0)
.then_with(|| a.1.hashed().cmp(&b.1.hashed()))
})
.map(|&(_, author)| vec![author])
.unwrap_or_default();
}
scored
.into_iter()
.filter(|&(d, _)| d / max_doa >= DOA_NORMALIZED_THRESHOLD)
.map(|(_, author)| author)
.collect()
}
struct TruckFactor {
removed: u32,
key_authors: Option<Vec<String>>,
}
fn greedy_truck_factor(
authors: &[GroupAuthor],
remaining_authors: &mut [u32],
coverage: f64,
emit: bool,
key: Option<&AuthorHashKey>,
) -> TruckFactor {
let total_files = remaining_authors.len();
#[allow(clippy::cast_precision_loss)] let target = coverage * total_files as f64;
let mut orphaned = 0usize;
let mut removed = 0u32;
let mut removed_set = vec![false; authors.len()];
let mut key_authors = emit.then(Vec::new);
#[allow(clippy::cast_precision_loss)]
while orphaned as f64 <= target {
let Some(pick) = pick_top_author(authors, &removed_set, remaining_authors) else {
break; };
removed_set[pick] = true;
removed = removed.saturating_add(1);
if let Some(ids) = key_authors.as_mut() {
let digest = &authors[pick].hashed;
ids.push(key.map_or_else(|| digest.clone(), |k| k.apply(digest)));
}
for &file_idx in &authors[pick].authored_files {
if let Some(count) = remaining_authors.get_mut(file_idx)
&& *count > 0
{
*count -= 1;
if *count == 0 {
orphaned += 1;
}
}
}
}
TruckFactor {
removed,
key_authors,
}
}
fn pick_top_author(
authors: &[GroupAuthor],
removed_set: &[bool],
remaining_authors: &[u32],
) -> Option<usize> {
let mut best: Option<(usize, usize)> = None; for (idx, author) in authors.iter().enumerate() {
if removed_set[idx] {
continue;
}
let covered = author
.authored_files
.iter()
.filter(|&&f| remaining_authors[f] > 0)
.count();
if covered == 0 {
continue;
}
let better = match best {
None => true,
Some((best_covered, best_idx)) => {
covered > best_covered
|| (covered == best_covered && authors[idx].hashed < authors[best_idx].hashed)
}
};
if better {
best = Some((covered, idx));
}
}
best.map(|(_, idx)| idx)
}
fn directory_keys(path: &Path) -> Vec<PathBuf> {
let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) else {
return Vec::new();
};
let mut components = parent.components().map(std::path::Component::as_os_str);
let Some(first) = components.next() else {
return Vec::new();
};
let mut keys = Vec::with_capacity(2);
keys.push(PathBuf::from(first));
if let Some(second) = components.next() {
let mut depth2 = PathBuf::from(first);
depth2.push(second);
keys.push(depth2);
}
keys
}
fn path_to_forward_slash(path: &Path) -> Option<String> {
path.to_str()
.map(|s| s.replace(std::path::MAIN_SEPARATOR, "/"))
}
fn clamp_threshold(threshold: f64) -> f64 {
const MIN: f64 = 1e-6;
const MAX: f64 = 1.0 - 1e-6;
if threshold.is_nan() {
DEFAULT_COVERAGE_THRESHOLD
} else {
threshold.clamp(MIN, MAX)
}
}
#[cfg(test)]
#[path = "bus_factor_tests.rs"]
mod tests;