gitstack 5.3.0

Git history viewer with insights - Author stats, file heatmap, code ownership
Documentation
//! Related file analysis
//!
//! Analyze files that are frequently changed together from commit history

use std::collections::HashMap;

/// Related file information
#[derive(Debug, Clone)]
pub struct RelatedFiles {
    /// Target file
    pub target_file: String,
    /// List of related files (file path, co-change count)
    pub related: Vec<(String, usize)>,
}

/// Analyze related files
///
/// target_file: Target file path
/// commit_files: Iterator that returns the list of changed files for each commit
pub fn analyze_related_files<I>(target_file: &str, commit_files: I) -> RelatedFiles
where
    I: Iterator<Item = Vec<String>>,
{
    let mut co_change_counts: HashMap<String, usize> = HashMap::new();

    for files in commit_files {
        // Check if this commit contains the target file
        if files.iter().any(|f| f == target_file) {
            // Count other files in the same commit
            for file in files {
                if file != target_file {
                    *co_change_counts.entry(file).or_insert(0) += 1;
                }
            }
        }
    }

    // Sort by count
    let mut related: Vec<(String, usize)> = co_change_counts.into_iter().collect();
    related.sort_by(|a, b| b.1.cmp(&a.1));

    // Limit to top 20
    related.truncate(20);

    RelatedFiles {
        target_file: target_file.to_string(),
        related,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_analyze_related_files_basic() {
        let commits = vec![
            vec!["src/a.rs".to_string(), "src/b.rs".to_string()],
            vec!["src/a.rs".to_string(), "src/c.rs".to_string()],
            vec![
                "src/a.rs".to_string(),
                "src/b.rs".to_string(),
                "src/c.rs".to_string(),
            ],
            vec!["src/d.rs".to_string()], // does not contain a.rs
        ];

        let result = analyze_related_files("src/a.rs", commits.into_iter());

        assert_eq!(result.target_file, "src/a.rs");
        // b.rs: 2 times, c.rs: 2 times
        assert!(result
            .related
            .iter()
            .any(|(f, c)| f == "src/b.rs" && *c == 2));
        assert!(result
            .related
            .iter()
            .any(|(f, c)| f == "src/c.rs" && *c == 2));
        // d.rs was not co-changed with a.rs
        assert!(!result.related.iter().any(|(f, _)| f == "src/d.rs"));
    }

    #[test]
    fn test_analyze_related_files_no_target() {
        let commits = vec![
            vec!["src/x.rs".to_string(), "src/y.rs".to_string()],
            vec!["src/z.rs".to_string()],
        ];

        let result = analyze_related_files("src/a.rs", commits.into_iter());

        assert!(result.related.is_empty());
    }

    #[test]
    fn test_analyze_related_files_empty() {
        let commits: Vec<Vec<String>> = vec![];
        let result = analyze_related_files("src/a.rs", commits.into_iter());
        assert!(result.related.is_empty());
    }
}