Skip to main content

argus_gitpulse/
ownership.rs

1//! Knowledge silo and bus factor analysis.
2//!
3//! Analyzes code ownership distribution across a project to identify
4//! knowledge silos (files dominated by a single author) and compute
5//! the project bus factor.
6
7use std::collections::HashMap;
8
9use argus_core::ArgusError;
10use serde::{Deserialize, Serialize};
11
12use crate::mining::CommitInfo;
13
14/// Ownership metrics for a single file.
15///
16/// # Examples
17///
18/// ```
19/// use argus_gitpulse::ownership::FileOwnership;
20///
21/// let ownership = FileOwnership {
22///     path: "src/main.rs".into(),
23///     total_commits: 20,
24///     authors: vec![],
25///     bus_factor: 3,
26///     dominant_author_ratio: 0.45,
27///     is_knowledge_silo: false,
28/// };
29/// assert!(!ownership.is_knowledge_silo);
30/// ```
31#[derive(Debug, Clone, Serialize, Deserialize)]
32#[serde(rename_all = "camelCase")]
33pub struct FileOwnership {
34    /// File path relative to repo root.
35    pub path: String,
36    /// Total commits touching this file.
37    pub total_commits: u32,
38    /// Per-author contribution breakdown.
39    pub authors: Vec<AuthorContribution>,
40    /// Number of authors with >10% contribution.
41    pub bus_factor: u32,
42    /// `max(author_commits) / total_commits`.
43    pub dominant_author_ratio: f64,
44    /// Whether `dominant_author_ratio > 0.80`.
45    pub is_knowledge_silo: bool,
46}
47
48/// Per-author contribution to a file.
49///
50/// # Examples
51///
52/// ```
53/// use argus_gitpulse::ownership::AuthorContribution;
54///
55/// let contrib = AuthorContribution {
56///     name: "alice".into(),
57///     email: "alice@example.com".into(),
58///     commits: 15,
59///     ratio: 0.75,
60/// };
61/// assert!(contrib.ratio > 0.5);
62/// ```
63#[derive(Debug, Clone, Serialize, Deserialize)]
64#[serde(rename_all = "camelCase")]
65pub struct AuthorContribution {
66    /// Author name.
67    pub name: String,
68    /// Author email.
69    pub email: String,
70    /// Number of commits by this author.
71    pub commits: u32,
72    /// `commits / total_commits` for this file.
73    pub ratio: f64,
74}
75
76/// Summary of knowledge distribution across the project.
77///
78/// # Examples
79///
80/// ```
81/// use argus_gitpulse::ownership::OwnershipSummary;
82///
83/// let summary = OwnershipSummary {
84///     total_files: 50,
85///     single_author_files: 10,
86///     knowledge_silos: 15,
87///     project_bus_factor: 2,
88///     files: vec![],
89/// };
90/// assert_eq!(summary.total_files, 50);
91/// ```
92#[derive(Debug, Clone, Serialize, Deserialize)]
93#[serde(rename_all = "camelCase")]
94pub struct OwnershipSummary {
95    /// Total files analyzed.
96    pub total_files: usize,
97    /// Files with only one author.
98    pub single_author_files: usize,
99    /// Files where one author has >80% of commits.
100    pub knowledge_silos: usize,
101    /// Minimum authors to remove to orphan >50% of files.
102    pub project_bus_factor: u32,
103    /// Per-file ownership data.
104    pub files: Vec<FileOwnership>,
105}
106
107/// Analyze code ownership and knowledge distribution.
108///
109/// # Errors
110///
111/// Returns [`ArgusError`] on processing failure.
112///
113/// # Examples
114///
115/// ```
116/// use argus_gitpulse::ownership::analyze_ownership;
117/// use argus_gitpulse::mining::{CommitInfo, FileChange, ChangeStatus};
118///
119/// let commits = vec![
120///     CommitInfo {
121///         hash: "abc".into(),
122///         author: "alice".into(),
123///         email: "alice@example.com".into(),
124///         timestamp: 1000,
125///         message: "init".into(),
126///         files_changed: vec![
127///             FileChange { path: "main.rs".into(), lines_added: 50, lines_deleted: 0, status: ChangeStatus::Added },
128///         ],
129///     },
130/// ];
131/// let summary = analyze_ownership(&commits).unwrap();
132/// assert_eq!(summary.total_files, 1);
133/// ```
134pub fn analyze_ownership(commits: &[CommitInfo]) -> Result<OwnershipSummary, ArgusError> {
135    // Accumulate per-file, per-author commit counts
136    // Key: file path, Value: map of (author_name, email) -> commit count
137    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        // Sort authors by commits descending, tie-breaking on email so the
177        // output order is deterministic across HashMap iteration runs.
178        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    // Sort by dominant_author_ratio descending (silos first)
206    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
223/// Compute the project bus factor.
224///
225/// Iteratively remove the top contributor until >50% of files lose
226/// all "significant" authors (those with >10% ratio).
227fn compute_project_bus_factor(files: &[FileOwnership]) -> u32 {
228    if files.is_empty() {
229        return 0;
230    }
231
232    // Collect all unique authors across all files
233    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    // Sort by file count descending, tie-breaking on email. Without the
241    // tie-break, HashMap iteration order makes the early-return below
242    // (orphaned > threshold) non-deterministic — different runs could
243    // remove different tied authors first and report different bus factors.
244    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        // Count files that have lost all significant authors
257        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        // alice: 3/4 = 0.75
348        assert!((file.dominant_author_ratio - 0.75).abs() < f64::EPSILON);
349        assert!(!file.is_knowledge_silo); // 0.75 < 0.80
350    }
351
352    #[test]
353    fn project_bus_factor_calculation() {
354        // alice owns file1 exclusively, bob owns file2 exclusively,
355        // carol owns file3 exclusively
356        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        // Removing any 2 authors orphans >50% of files (2 out of 3)
364        assert_eq!(summary.project_bus_factor, 2);
365    }
366}