Skip to main content

gitstack/
relevance.rs

1//! 関連コミット優先機能
2//!
3//! ワーキングツリーの変更ファイルに関連するコミットを優先表示
4
5use std::collections::HashSet;
6
7use crate::event::GitEvent;
8
9/// 関連スコアの計算結果
10#[derive(Debug, Clone)]
11pub struct RelevanceScore {
12    /// コミットハッシュ(短縮形)
13    pub hash: String,
14    /// スコア(0.0 - 1.0)
15    pub score: f32,
16    /// マッチしたファイル数
17    pub matched_files: usize,
18}
19
20/// コミットの関連スコアを計算
21///
22/// working_files: ワーキングツリーで変更されているファイル
23/// events: イベント一覧
24/// get_commit_files: コミットのファイル一覧を取得するクロージャ
25pub fn calculate_relevance<F>(
26    working_files: &[String],
27    events: &[&GitEvent],
28    get_commit_files: F,
29) -> Vec<RelevanceScore>
30where
31    F: Fn(&str) -> Option<Vec<String>>,
32{
33    if working_files.is_empty() {
34        return Vec::new();
35    }
36
37    let working_set: HashSet<&str> = working_files.iter().map(|s| s.as_str()).collect();
38
39    events
40        .iter()
41        .filter_map(|event| {
42            let files = get_commit_files(&event.short_hash)?;
43            let matched = files
44                .iter()
45                .filter(|f| working_set.contains(f.as_str()))
46                .count();
47
48            if matched == 0 {
49                return None;
50            }
51
52            // スコア計算: マッチ率 * ファイル数の重み
53            let match_ratio = matched as f32 / working_files.len() as f32;
54            let file_weight = (matched as f32).sqrt() / (files.len() as f32).sqrt().max(1.0);
55            let score = (match_ratio * 0.7 + file_weight * 0.3).min(1.0);
56
57            Some(RelevanceScore {
58                hash: event.short_hash.clone(),
59                score,
60                matched_files: matched,
61            })
62        })
63        .collect()
64}
65
66/// 関連スコアでソートされたインデックスを取得
67pub fn sort_by_relevance(scores: &[RelevanceScore]) -> Vec<(usize, f32)> {
68    let mut indexed: Vec<(usize, f32)> = scores
69        .iter()
70        .enumerate()
71        .map(|(i, s)| (i, s.score))
72        .collect();
73    indexed.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
74    indexed
75}
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80    use crate::event::GitEvent;
81    use chrono::Local;
82
83    fn create_test_event(hash: &str) -> GitEvent {
84        GitEvent::commit(
85            hash.to_string(),
86            "test".to_string(),
87            "author".to_string(),
88            Local::now(),
89            1,
90            0,
91        )
92    }
93
94    #[test]
95    fn test_calculate_relevance_empty_working_files() {
96        let event = create_test_event("abc1234");
97        let events = vec![&event];
98        let scores = calculate_relevance(&[], &events, |_| Some(vec!["file.rs".to_string()]));
99        assert!(scores.is_empty());
100    }
101
102    #[test]
103    fn test_calculate_relevance_with_matches() {
104        let event = create_test_event("abc1234");
105        let events = vec![&event];
106        let working_files = vec!["src/main.rs".to_string(), "src/lib.rs".to_string()];
107
108        let scores = calculate_relevance(&working_files, &events, |_| {
109            Some(vec!["src/main.rs".to_string(), "other.rs".to_string()])
110        });
111
112        assert_eq!(scores.len(), 1);
113        assert_eq!(scores[0].matched_files, 1);
114        assert!(scores[0].score > 0.0);
115    }
116
117    #[test]
118    fn test_calculate_relevance_no_matches() {
119        let event = create_test_event("abc1234");
120        let events = vec![&event];
121        let working_files = vec!["src/main.rs".to_string()];
122
123        let scores = calculate_relevance(&working_files, &events, |_| {
124            Some(vec!["other.rs".to_string()])
125        });
126
127        assert!(scores.is_empty());
128    }
129}