use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use serde::Serialize;
use crate::git::BlameInfo;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum RiskLevel {
Critical,
High,
Medium,
Low,
}
impl RiskLevel {
pub fn label(&self) -> &'static str {
match self {
RiskLevel::Critical => "CRITICAL",
RiskLevel::High => "HIGH",
RiskLevel::Medium => "MEDIUM",
RiskLevel::Low => "LOW",
}
}
pub fn sort_key(&self) -> u8 {
match self {
RiskLevel::Critical => 0,
RiskLevel::High => 1,
RiskLevel::Medium => 2,
RiskLevel::Low => 3,
}
}
}
struct AuthorContribution {
author: String,
email: String,
percentage: f64,
active: bool,
}
pub struct FileOwnership {
pub path: PathBuf,
pub language: String,
pub total_lines: usize,
pub primary_owner: String,
pub primary_email: String,
pub ownership_pct: f64,
pub contributors: usize,
pub risk: RiskLevel,
pub knowledge_loss: bool,
}
pub fn compute_ownership(
path: PathBuf,
language: &str,
blames: &[BlameInfo],
recent_authors: &HashSet<String>,
) -> FileOwnership {
let total_lines: usize = blames.iter().map(|b| b.lines).sum();
if total_lines == 0 || blames.is_empty() {
return FileOwnership {
path,
language: language.to_string(),
total_lines: 0,
primary_owner: "unknown".to_string(),
primary_email: String::new(),
ownership_pct: 0.0,
contributors: 0,
risk: RiskLevel::Low,
knowledge_loss: false,
};
}
let contributions: Vec<AuthorContribution> = blames
.iter()
.map(|b| {
let pct = (b.lines as f64 / total_lines as f64) * 100.0;
AuthorContribution {
author: b.author.clone(),
email: b.email.clone(),
percentage: pct,
active: recent_authors.contains(&b.email),
}
})
.collect();
let primary = &contributions[0]; let risk = classify_risk(&contributions);
let knowledge_loss = !recent_authors.is_empty() && !primary.active;
FileOwnership {
path,
language: language.to_string(),
total_lines,
primary_owner: primary.author.clone(),
primary_email: primary.email.clone(),
ownership_pct: primary.percentage,
contributors: contributions.len(),
risk,
knowledge_loss,
}
}
fn classify_risk(contributors: &[AuthorContribution]) -> RiskLevel {
if contributors.is_empty() {
return RiskLevel::Low;
}
let top_pct = contributors[0].percentage;
if top_pct >= 80.0 {
return RiskLevel::Critical;
}
if top_pct >= 60.0 {
return RiskLevel::High;
}
let top_combined: f64 = contributors.iter().take(3).map(|c| c.percentage).sum();
if top_combined >= 80.0 {
return RiskLevel::Medium;
}
RiskLevel::Low
}
pub struct AuthorSummary {
pub author: String,
pub files_owned: usize,
pub total_lines: usize,
pub languages: Vec<String>,
pub worst_risk: RiskLevel,
pub knowledge_loss_files: usize,
}
pub fn aggregate_by_author(files: &[FileOwnership]) -> Vec<AuthorSummary> {
use std::collections::{BTreeMap, BTreeSet};
let mut map: BTreeMap<String, (usize, usize, BTreeSet<String>, RiskLevel, usize)> =
BTreeMap::new();
for f in files {
let entry = map.entry(f.primary_owner.clone()).or_insert((
0,
0,
BTreeSet::new(),
RiskLevel::Low,
0,
));
entry.0 += 1;
entry.1 += f.total_lines;
entry.2.insert(f.language.clone());
if f.risk.sort_key() < entry.3.sort_key() {
entry.3 = f.risk;
}
if f.knowledge_loss {
entry.4 += 1;
}
}
map.into_iter()
.map(
|(author, (files_owned, total_lines, languages, worst_risk, knowledge_loss_files))| {
AuthorSummary {
author,
files_owned,
total_lines,
languages: languages.into_iter().collect(),
worst_risk,
knowledge_loss_files,
}
},
)
.collect()
}
pub struct BusFactorEntry {
pub author: String,
pub lines: usize,
pub pct: f64,
pub cumulative_pct: f64,
pub is_critical: bool,
}
pub struct BusFactor {
pub factor: usize,
pub threshold: f64,
pub total_lines: usize,
pub contributors: Vec<BusFactorEntry>,
}
pub fn compute_bus_factor(author_lines: &HashMap<String, usize>, threshold: f64) -> BusFactor {
let total_lines: usize = author_lines.values().sum();
if total_lines == 0 {
return BusFactor {
factor: 0,
threshold,
total_lines: 0,
contributors: vec![],
};
}
let mut sorted: Vec<(String, usize)> =
author_lines.iter().map(|(k, &v)| (k.clone(), v)).collect();
sorted.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
let mut cumulative = 0.0;
let mut contributors: Vec<BusFactorEntry> = Vec::new();
for (author, lines) in sorted {
let pct = lines as f64 / total_lines as f64 * 100.0;
let is_critical = cumulative < threshold;
cumulative += pct;
contributors.push(BusFactorEntry {
author,
lines,
pct,
cumulative_pct: cumulative,
is_critical,
});
}
let factor = contributors.iter().filter(|e| e.is_critical).count();
BusFactor {
factor,
threshold,
total_lines,
contributors,
}
}
#[cfg(test)]
#[path = "analyzer_test.rs"]
mod tests;