1use std::collections::HashMap;
8
9use argus_core::ArgusError;
10use serde::{Deserialize, Serialize};
11
12use crate::mining::CommitInfo;
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
32#[serde(rename_all = "camelCase")]
33pub struct FileOwnership {
34 pub path: String,
36 pub total_commits: u32,
38 pub authors: Vec<AuthorContribution>,
40 pub bus_factor: u32,
42 pub dominant_author_ratio: f64,
44 pub is_knowledge_silo: bool,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
64#[serde(rename_all = "camelCase")]
65pub struct AuthorContribution {
66 pub name: String,
68 pub email: String,
70 pub commits: u32,
72 pub ratio: f64,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
93#[serde(rename_all = "camelCase")]
94pub struct OwnershipSummary {
95 pub total_files: usize,
97 pub single_author_files: usize,
99 pub knowledge_silos: usize,
101 pub project_bus_factor: u32,
103 pub files: Vec<FileOwnership>,
105}
106
107pub fn analyze_ownership(commits: &[CommitInfo]) -> Result<OwnershipSummary, ArgusError> {
135 let mut file_authors: HashMap<String, HashMap<(String, String), u32>> = HashMap::new();
138
139 for commit in commits {
140 let author_key = (commit.author.clone(), commit.email.clone());
141 for file in &commit.files_changed {
142 *file_authors
143 .entry(file.path.clone())
144 .or_default()
145 .entry(author_key.clone())
146 .or_default() += 1;
147 }
148 }
149
150 let mut files = Vec::new();
151 let mut single_author_files = 0usize;
152 let mut knowledge_silos = 0usize;
153
154 for (path, author_map) in &file_authors {
155 let total_commits: u32 = author_map.values().sum();
156 if total_commits == 0 {
157 continue;
158 }
159
160 let mut author_contribs: Vec<AuthorContribution> = Vec::new();
161 let mut max_commits = 0u32;
162
163 for ((name, email), count) in author_map {
164 let ratio = *count as f64 / total_commits as f64;
165 if *count > max_commits {
166 max_commits = *count;
167 }
168 author_contribs.push(AuthorContribution {
169 name: name.clone(),
170 email: email.clone(),
171 commits: *count,
172 ratio,
173 });
174 }
175
176 author_contribs.sort_by(|a, b| {
179 b.commits
180 .cmp(&a.commits)
181 .then_with(|| a.email.cmp(&b.email))
182 });
183
184 let dominant_author_ratio = max_commits as f64 / total_commits as f64;
185 let bus_factor = author_contribs.iter().filter(|a| a.ratio > 0.10).count() as u32;
186 let is_silo = dominant_author_ratio > 0.80;
187
188 if author_contribs.len() == 1 {
189 single_author_files += 1;
190 }
191 if is_silo {
192 knowledge_silos += 1;
193 }
194
195 files.push(FileOwnership {
196 path: path.clone(),
197 total_commits,
198 authors: author_contribs,
199 bus_factor,
200 dominant_author_ratio,
201 is_knowledge_silo: is_silo,
202 });
203 }
204
205 files.sort_by(|a, b| {
207 b.dominant_author_ratio
208 .partial_cmp(&a.dominant_author_ratio)
209 .unwrap_or(std::cmp::Ordering::Equal)
210 });
211
212 let project_bus_factor = compute_project_bus_factor(&files);
213
214 Ok(OwnershipSummary {
215 total_files: files.len(),
216 single_author_files,
217 knowledge_silos,
218 project_bus_factor,
219 files,
220 })
221}
222
223fn compute_project_bus_factor(files: &[FileOwnership]) -> u32 {
228 if files.is_empty() {
229 return 0;
230 }
231
232 let mut all_authors: HashMap<String, u32> = HashMap::new();
234 for file in files {
235 for author in &file.authors {
236 *all_authors.entry(author.email.clone()).or_default() += 1;
237 }
238 }
239
240 let mut sorted_authors: Vec<(String, u32)> = all_authors.into_iter().collect();
245 sorted_authors.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
246
247 let total_files = files.len();
248 let threshold = total_files / 2;
249 let mut removed_authors: std::collections::HashSet<String> = std::collections::HashSet::new();
250 let mut removals = 0u32;
251
252 for (author_email, _) in &sorted_authors {
253 removed_authors.insert(author_email.clone());
254 removals += 1;
255
256 let mut orphaned = 0usize;
258 for file in files {
259 let has_significant_author = file
260 .authors
261 .iter()
262 .any(|a| a.ratio > 0.10 && !removed_authors.contains(&a.email));
263 if !has_significant_author {
264 orphaned += 1;
265 }
266 }
267
268 if orphaned > threshold {
269 return removals;
270 }
271 }
272
273 removals
274}
275
276#[cfg(test)]
277mod tests {
278 use super::*;
279 use crate::mining::{ChangeStatus, FileChange};
280
281 fn make_commit(author: &str, email: &str, files: Vec<&str>) -> CommitInfo {
282 CommitInfo {
283 hash: "abc".into(),
284 author: author.into(),
285 email: email.into(),
286 timestamp: 1000,
287 message: "test".into(),
288 files_changed: files
289 .into_iter()
290 .map(|path| FileChange {
291 path: path.into(),
292 lines_added: 5,
293 lines_deleted: 2,
294 status: ChangeStatus::Modified,
295 })
296 .collect(),
297 }
298 }
299
300 #[test]
301 fn single_author_file_is_knowledge_silo() {
302 let commits = vec![
303 make_commit("alice", "alice@example.com", vec!["main.rs"]),
304 make_commit("alice", "alice@example.com", vec!["main.rs"]),
305 make_commit("alice", "alice@example.com", vec!["main.rs"]),
306 ];
307
308 let summary = analyze_ownership(&commits).unwrap();
309 assert_eq!(summary.total_files, 1);
310 assert_eq!(summary.single_author_files, 1);
311 assert_eq!(summary.knowledge_silos, 1);
312
313 let file = &summary.files[0];
314 assert_eq!(file.bus_factor, 1);
315 assert!(file.is_knowledge_silo);
316 assert!((file.dominant_author_ratio - 1.0).abs() < f64::EPSILON);
317 }
318
319 #[test]
320 fn five_equal_authors_not_a_silo() {
321 let commits = vec![
322 make_commit("alice", "alice@e.com", vec!["main.rs"]),
323 make_commit("bob", "bob@e.com", vec!["main.rs"]),
324 make_commit("carol", "carol@e.com", vec!["main.rs"]),
325 make_commit("dave", "dave@e.com", vec!["main.rs"]),
326 make_commit("eve", "eve@e.com", vec!["main.rs"]),
327 ];
328
329 let summary = analyze_ownership(&commits).unwrap();
330 let file = &summary.files[0];
331 assert_eq!(file.bus_factor, 5);
332 assert!(!file.is_knowledge_silo);
333 assert!((file.dominant_author_ratio - 0.2).abs() < f64::EPSILON);
334 }
335
336 #[test]
337 fn dominant_author_ratio_calculation() {
338 let commits = vec![
339 make_commit("alice", "alice@e.com", vec!["main.rs"]),
340 make_commit("alice", "alice@e.com", vec!["main.rs"]),
341 make_commit("alice", "alice@e.com", vec!["main.rs"]),
342 make_commit("bob", "bob@e.com", vec!["main.rs"]),
343 ];
344
345 let summary = analyze_ownership(&commits).unwrap();
346 let file = &summary.files[0];
347 assert!((file.dominant_author_ratio - 0.75).abs() < f64::EPSILON);
349 assert!(!file.is_knowledge_silo); }
351
352 #[test]
353 fn project_bus_factor_calculation() {
354 let commits = vec![
357 make_commit("alice", "alice@e.com", vec!["file1.rs"]),
358 make_commit("bob", "bob@e.com", vec!["file2.rs"]),
359 make_commit("carol", "carol@e.com", vec!["file3.rs"]),
360 ];
361
362 let summary = analyze_ownership(&commits).unwrap();
363 assert_eq!(summary.project_bus_factor, 2);
365 }
366}