gitstack 5.3.0

Git history viewer with insights - Author stats, file heatmap, code ownership
Documentation
//! Related commit prioritization
//!
//! Prioritize display of commits related to working tree changes

use std::collections::HashSet;

use crate::event::GitEvent;

const MATCH_RATIO_WEIGHT: f32 = 0.7;
const FILE_WEIGHT: f32 = 0.3;

/// Relevance score calculation result
#[derive(Debug, Clone)]
pub struct RelevanceScore {
    /// Commit hash (abbreviated)
    pub hash: String,
    /// Score (0.0 - 1.0)
    pub score: f32,
    /// Number of matched files
    pub matched_files: usize,
}

/// Calculate relevance scores for commits
///
/// working_files: Files changed in the working tree
/// events: List of events
/// get_commit_files: Closure to get the list of files for a commit
pub fn calculate_relevance<F>(
    working_files: &[String],
    events: &[&GitEvent],
    get_commit_files: F,
) -> Vec<RelevanceScore>
where
    F: Fn(&str) -> Option<Vec<String>>,
{
    if working_files.is_empty() {
        return Vec::new();
    }

    let working_set: HashSet<&str> = working_files.iter().map(|s| s.as_str()).collect();

    events
        .iter()
        .filter_map(|event| {
            let files = get_commit_files(&event.short_hash)?;
            let matched = files
                .iter()
                .filter(|f| working_set.contains(f.as_str()))
                .count();

            if matched == 0 {
                return None;
            }

            // Score calculation: match ratio * file count weight
            let match_ratio = matched as f32 / working_files.len() as f32;
            let file_weight = (matched as f32).sqrt() / (files.len() as f32).sqrt().max(1.0);
            let score = (match_ratio * MATCH_RATIO_WEIGHT + file_weight * FILE_WEIGHT).min(1.0);

            Some(RelevanceScore {
                hash: event.short_hash.clone(),
                score,
                matched_files: matched,
            })
        })
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::event::GitEvent;
    use chrono::Local;

    fn create_test_event(hash: &str) -> GitEvent {
        GitEvent::commit(
            hash.to_string(),
            "test".to_string(),
            "author".to_string(),
            Local::now(),
            1,
            0,
        )
    }

    #[test]
    fn test_calculate_relevance_empty_working_files() {
        let event = create_test_event("abc1234");
        let events = vec![&event];
        let scores = calculate_relevance(&[], &events, |_| Some(vec!["file.rs".to_string()]));
        assert!(scores.is_empty());
    }

    #[test]
    fn test_calculate_relevance_with_matches() {
        let event = create_test_event("abc1234");
        let events = vec![&event];
        let working_files = vec!["src/main.rs".to_string(), "src/lib.rs".to_string()];

        let scores = calculate_relevance(&working_files, &events, |_| {
            Some(vec!["src/main.rs".to_string(), "other.rs".to_string()])
        });

        assert_eq!(scores.len(), 1);
        assert_eq!(scores[0].matched_files, 1);
        assert!(scores[0].score > 0.0);
    }

    #[test]
    fn test_calculate_relevance_no_matches() {
        let event = create_test_event("abc1234");
        let events = vec![&event];
        let working_files = vec!["src/main.rs".to_string()];

        let scores = calculate_relevance(&working_files, &events, |_| {
            Some(vec!["other.rs".to_string()])
        });

        assert!(scores.is_empty());
    }
}