use std::collections::HashMap;
use argus_core::ArgusError;
use serde::{Deserialize, Serialize};
use crate::mining::CommitInfo;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FileOwnership {
pub path: String,
pub total_commits: u32,
pub authors: Vec<AuthorContribution>,
pub bus_factor: u32,
pub dominant_author_ratio: f64,
pub is_knowledge_silo: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AuthorContribution {
pub name: String,
pub email: String,
pub commits: u32,
pub ratio: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OwnershipSummary {
pub total_files: usize,
pub single_author_files: usize,
pub knowledge_silos: usize,
pub project_bus_factor: u32,
pub files: Vec<FileOwnership>,
}
pub fn analyze_ownership(commits: &[CommitInfo]) -> Result<OwnershipSummary, ArgusError> {
let mut file_authors: HashMap<String, HashMap<(String, String), u32>> = HashMap::new();
for commit in commits {
let author_key = (commit.author.clone(), commit.email.clone());
for file in &commit.files_changed {
*file_authors
.entry(file.path.clone())
.or_default()
.entry(author_key.clone())
.or_default() += 1;
}
}
let mut files = Vec::new();
let mut single_author_files = 0usize;
let mut knowledge_silos = 0usize;
for (path, author_map) in &file_authors {
let total_commits: u32 = author_map.values().sum();
if total_commits == 0 {
continue;
}
let mut author_contribs: Vec<AuthorContribution> = Vec::new();
let mut max_commits = 0u32;
for ((name, email), count) in author_map {
let ratio = *count as f64 / total_commits as f64;
if *count > max_commits {
max_commits = *count;
}
author_contribs.push(AuthorContribution {
name: name.clone(),
email: email.clone(),
commits: *count,
ratio,
});
}
author_contribs.sort_by(|a, b| {
b.commits
.cmp(&a.commits)
.then_with(|| a.email.cmp(&b.email))
});
let dominant_author_ratio = max_commits as f64 / total_commits as f64;
let bus_factor = author_contribs.iter().filter(|a| a.ratio > 0.10).count() as u32;
let is_silo = dominant_author_ratio > 0.80;
if author_contribs.len() == 1 {
single_author_files += 1;
}
if is_silo {
knowledge_silos += 1;
}
files.push(FileOwnership {
path: path.clone(),
total_commits,
authors: author_contribs,
bus_factor,
dominant_author_ratio,
is_knowledge_silo: is_silo,
});
}
files.sort_by(|a, b| {
b.dominant_author_ratio
.partial_cmp(&a.dominant_author_ratio)
.unwrap_or(std::cmp::Ordering::Equal)
});
let project_bus_factor = compute_project_bus_factor(&files);
Ok(OwnershipSummary {
total_files: files.len(),
single_author_files,
knowledge_silos,
project_bus_factor,
files,
})
}
fn compute_project_bus_factor(files: &[FileOwnership]) -> u32 {
if files.is_empty() {
return 0;
}
let mut all_authors: HashMap<String, u32> = HashMap::new();
for file in files {
for author in &file.authors {
*all_authors.entry(author.email.clone()).or_default() += 1;
}
}
let mut sorted_authors: Vec<(String, u32)> = all_authors.into_iter().collect();
sorted_authors.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
let total_files = files.len();
let threshold = total_files / 2;
let mut removed_authors: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut removals = 0u32;
for (author_email, _) in &sorted_authors {
removed_authors.insert(author_email.clone());
removals += 1;
let mut orphaned = 0usize;
for file in files {
let has_significant_author = file
.authors
.iter()
.any(|a| a.ratio > 0.10 && !removed_authors.contains(&a.email));
if !has_significant_author {
orphaned += 1;
}
}
if orphaned > threshold {
return removals;
}
}
removals
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mining::{ChangeStatus, FileChange};
fn make_commit(author: &str, email: &str, files: Vec<&str>) -> CommitInfo {
CommitInfo {
hash: "abc".into(),
author: author.into(),
email: email.into(),
timestamp: 1000,
message: "test".into(),
files_changed: files
.into_iter()
.map(|path| FileChange {
path: path.into(),
lines_added: 5,
lines_deleted: 2,
status: ChangeStatus::Modified,
})
.collect(),
}
}
#[test]
fn single_author_file_is_knowledge_silo() {
let commits = vec![
make_commit("alice", "alice@example.com", vec!["main.rs"]),
make_commit("alice", "alice@example.com", vec!["main.rs"]),
make_commit("alice", "alice@example.com", vec!["main.rs"]),
];
let summary = analyze_ownership(&commits).unwrap();
assert_eq!(summary.total_files, 1);
assert_eq!(summary.single_author_files, 1);
assert_eq!(summary.knowledge_silos, 1);
let file = &summary.files[0];
assert_eq!(file.bus_factor, 1);
assert!(file.is_knowledge_silo);
assert!((file.dominant_author_ratio - 1.0).abs() < f64::EPSILON);
}
#[test]
fn five_equal_authors_not_a_silo() {
let commits = vec![
make_commit("alice", "alice@e.com", vec!["main.rs"]),
make_commit("bob", "bob@e.com", vec!["main.rs"]),
make_commit("carol", "carol@e.com", vec!["main.rs"]),
make_commit("dave", "dave@e.com", vec!["main.rs"]),
make_commit("eve", "eve@e.com", vec!["main.rs"]),
];
let summary = analyze_ownership(&commits).unwrap();
let file = &summary.files[0];
assert_eq!(file.bus_factor, 5);
assert!(!file.is_knowledge_silo);
assert!((file.dominant_author_ratio - 0.2).abs() < f64::EPSILON);
}
#[test]
fn dominant_author_ratio_calculation() {
let commits = vec![
make_commit("alice", "alice@e.com", vec!["main.rs"]),
make_commit("alice", "alice@e.com", vec!["main.rs"]),
make_commit("alice", "alice@e.com", vec!["main.rs"]),
make_commit("bob", "bob@e.com", vec!["main.rs"]),
];
let summary = analyze_ownership(&commits).unwrap();
let file = &summary.files[0];
assert!((file.dominant_author_ratio - 0.75).abs() < f64::EPSILON);
assert!(!file.is_knowledge_silo); }
#[test]
fn project_bus_factor_calculation() {
let commits = vec![
make_commit("alice", "alice@e.com", vec!["file1.rs"]),
make_commit("bob", "bob@e.com", vec!["file2.rs"]),
make_commit("carol", "carol@e.com", vec!["file3.rs"]),
];
let summary = analyze_ownership(&commits).unwrap();
assert_eq!(summary.project_bus_factor, 2);
}
}