Skip to main content

gitstack/
stats.rs

1//! 統計情報モジュール
2//!
3//! リポジトリの著者別統計やファイル変更頻度を計算する
4
5use chrono::{DateTime, Local};
6use std::collections::HashMap;
7
8use crate::event::GitEvent;
9
10// =============================================================================
11// Impact Score
12// =============================================================================
13
14/// コミットのImpact Score(影響度スコア)
15#[derive(Debug, Clone)]
16pub struct CommitImpactScore {
17    /// コミットハッシュ
18    pub commit_hash: String,
19    /// コミットメッセージ
20    pub commit_message: String,
21    /// 著者
22    pub author: String,
23    /// 日付
24    pub date: DateTime<Local>,
25    /// 変更ファイル数
26    pub files_changed: usize,
27    /// 追加行数
28    pub insertions: usize,
29    /// 削除行数
30    pub deletions: usize,
31    /// 総合スコア (0.0〜1.0)
32    pub score: f64,
33    /// ファイル数要素 (0.0〜0.4)
34    pub file_score: f64,
35    /// 変更行数要素 (0.0〜0.4)
36    pub change_score: f64,
37    /// ファイル重要度要素 (0.0〜0.2)
38    pub heat_score: f64,
39}
40
41impl CommitImpactScore {
42    /// スコアに応じた色を取得
43    pub fn score_color(&self) -> &'static str {
44        if self.score >= 0.7 {
45            "red" // High Impact
46        } else if self.score >= 0.4 {
47            "yellow" // Medium Impact
48        } else {
49            "green" // Low Impact
50        }
51    }
52
53    /// スコアバーを生成(5段階)
54    pub fn score_bar(&self) -> &'static str {
55        if self.score >= 0.8 {
56            "█████"
57        } else if self.score >= 0.6 {
58            "████ "
59        } else if self.score >= 0.4 {
60            "███  "
61        } else if self.score >= 0.2 {
62            "██   "
63        } else {
64            "█    "
65        }
66    }
67}
68
69/// Impact Score分析結果
70#[derive(Debug, Clone, Default)]
71pub struct CommitImpactAnalysis {
72    /// コミット一覧(スコア降順)
73    pub commits: Vec<CommitImpactScore>,
74    /// 総コミット数
75    pub total_commits: usize,
76    /// 平均スコア
77    pub avg_score: f64,
78    /// 最大スコア
79    pub max_score: f64,
80    /// High Impact(スコア >= 0.7)のコミット数
81    pub high_impact_count: usize,
82}
83
84impl CommitImpactAnalysis {
85    /// コミット数を取得
86    pub fn commit_count(&self) -> usize {
87        self.commits.len()
88    }
89}
90
91/// Impact Scoreを計算
92///
93/// 計算式:
94/// - ファイル数要素: (files_changed / 50).min(1.0) * 0.4
95/// - 変更行数要素: (total_changes / 500).min(1.0) * 0.4
96/// - ファイル重要度要素: avg_file_heat * 0.2
97pub fn calculate_impact_scores(
98    events: &[&GitEvent],
99    get_files: impl Fn(&str) -> Option<Vec<String>>,
100    file_heatmap: &FileHeatmap,
101) -> CommitImpactAnalysis {
102    let mut commits = Vec::new();
103    let mut total_score = 0.0;
104    let mut max_score = 0.0f64;
105    let mut high_impact_count = 0;
106
107    // ファイルパスからヒート値を取得するためのマップを作成
108    let heat_map: HashMap<&str, f64> = file_heatmap
109        .files
110        .iter()
111        .map(|f| (f.path.as_str(), f.heat_level()))
112        .collect();
113
114    for event in events {
115        let files = get_files(&event.short_hash).unwrap_or_default();
116        let files_changed = files.len();
117        let total_changes = event.files_added + event.files_deleted;
118
119        // ファイル数要素 (40%)
120        let file_score = (files_changed as f64 / 50.0).min(1.0) * 0.4;
121
122        // 変更行数要素 (40%)
123        let change_score = (total_changes as f64 / 500.0).min(1.0) * 0.4;
124
125        // ファイル重要度要素 (20%)
126        let avg_file_heat = if files.is_empty() {
127            0.0
128        } else {
129            let total_heat: f64 = files
130                .iter()
131                .map(|f| heat_map.get(f.as_str()).copied().unwrap_or(0.0))
132                .sum();
133            total_heat / files.len() as f64
134        };
135        let heat_score = avg_file_heat * 0.2;
136
137        // 総合スコア
138        let score = file_score + change_score + heat_score;
139
140        commits.push(CommitImpactScore {
141            commit_hash: event.short_hash.clone(),
142            commit_message: event.message.clone(),
143            author: event.author.clone(),
144            date: event.timestamp,
145            files_changed,
146            insertions: event.files_added,
147            deletions: event.files_deleted,
148            score,
149            file_score,
150            change_score,
151            heat_score,
152        });
153
154        total_score += score;
155        max_score = max_score.max(score);
156        if score >= 0.7 {
157            high_impact_count += 1;
158        }
159    }
160
161    // スコア降順でソート
162    commits.sort_by(|a, b| {
163        b.score
164            .partial_cmp(&a.score)
165            .unwrap_or(std::cmp::Ordering::Equal)
166    });
167
168    let total_commits = commits.len();
169    let avg_score = if total_commits > 0 {
170        total_score / total_commits as f64
171    } else {
172        0.0
173    };
174
175    CommitImpactAnalysis {
176        commits,
177        total_commits,
178        avg_score,
179        max_score,
180        high_impact_count,
181    }
182}
183
184// =============================================================================
185// Change Coupling
186// =============================================================================
187
188/// ファイル間の共変更関係(Change Coupling)
189#[derive(Debug, Clone)]
190pub struct FileCoupling {
191    /// 対象ファイル
192    pub file: String,
193    /// 結合先ファイル
194    pub coupled_file: String,
195    /// 共変更回数
196    pub co_change_count: usize,
197    /// 対象ファイルの総変更回数
198    pub file_change_count: usize,
199    /// 結合度(0.0-1.0)
200    pub coupling_percent: f64,
201}
202
203impl FileCoupling {
204    /// 結合度バーを生成(10段階)
205    pub fn coupling_bar(&self) -> String {
206        let filled = (self.coupling_percent * 10.0).round() as usize;
207        let empty = 10 - filled;
208        format!("[{}{}]", "█".repeat(filled), "░".repeat(empty))
209    }
210}
211
212/// Change Coupling分析結果
213#[derive(Debug, Clone, Default)]
214pub struct ChangeCouplingAnalysis {
215    /// ファイル間の結合関係(結合度順)
216    pub couplings: Vec<FileCoupling>,
217    /// 高結合(70%以上)の数
218    pub high_coupling_count: usize,
219    /// 分析されたファイル数
220    pub total_files_analyzed: usize,
221}
222
223impl ChangeCouplingAnalysis {
224    /// 結合数を取得
225    pub fn coupling_count(&self) -> usize {
226        self.couplings.len()
227    }
228
229    /// ファイル別にグループ化された結合を取得
230    pub fn grouped_by_file(&self) -> Vec<(&str, Vec<&FileCoupling>)> {
231        use std::collections::HashMap;
232        let mut groups: HashMap<&str, Vec<&FileCoupling>> = HashMap::new();
233        for coupling in &self.couplings {
234            groups.entry(&coupling.file).or_default().push(coupling);
235        }
236        let mut result: Vec<_> = groups.into_iter().collect();
237        // ファイルの総変更回数でソート(降順)
238        result.sort_by(|a, b| {
239            let count_a = a.1.first().map(|c| c.file_change_count).unwrap_or(0);
240            let count_b = b.1.first().map(|c| c.file_change_count).unwrap_or(0);
241            count_b.cmp(&count_a)
242        });
243        result
244    }
245}
246
247/// Change Coupling(共変更関係)を計算
248///
249/// # Arguments
250/// * `events` - コミットイベント一覧
251/// * `get_files` - コミットハッシュからファイル一覧を取得するクロージャ
252/// * `min_commits` - 最低コミット数(これ未満のファイルは除外)
253/// * `min_coupling` - 最低結合度(これ未満の結合は除外)
254///
255/// # Returns
256/// Change Coupling分析結果
257pub fn calculate_change_coupling(
258    events: &[&GitEvent],
259    get_files: impl Fn(&str) -> Option<Vec<String>>,
260    min_commits: usize,
261    min_coupling: f64,
262) -> ChangeCouplingAnalysis {
263    use std::collections::{HashMap, HashSet};
264
265    // 1. 各ファイルの変更回数をカウント
266    let mut file_change_counts: HashMap<String, usize> = HashMap::new();
267
268    // 2. 各コミットのファイル一覧を収集
269    let mut commit_files: Vec<HashSet<String>> = Vec::new();
270
271    for event in events {
272        if let Some(files) = get_files(&event.short_hash) {
273            let file_set: HashSet<String> = files.iter().cloned().collect();
274            for file in &file_set {
275                *file_change_counts.entry(file.clone()).or_insert(0) += 1;
276            }
277            commit_files.push(file_set);
278        }
279    }
280
281    // 3. ファイルペアごとの共変更回数をカウント
282    let mut pair_counts: HashMap<(String, String), usize> = HashMap::new();
283
284    for files in &commit_files {
285        let files_vec: Vec<_> = files.iter().collect();
286        for i in 0..files_vec.len() {
287            for j in (i + 1)..files_vec.len() {
288                let file_a = files_vec[i].clone();
289                let file_b = files_vec[j].clone();
290                // 両方向で記録(A→BとB→A)
291                *pair_counts
292                    .entry((file_a.clone(), file_b.clone()))
293                    .or_insert(0) += 1;
294                *pair_counts.entry((file_b, file_a)).or_insert(0) += 1;
295            }
296        }
297    }
298
299    // 4. 結合度を計算してフィルタリング
300    let mut couplings: Vec<FileCoupling> = Vec::new();
301    let mut high_coupling_count = 0;
302    let mut analyzed_files: HashSet<String> = HashSet::new();
303
304    for ((file, coupled_file), co_change_count) in &pair_counts {
305        let file_change_count = *file_change_counts.get(file).unwrap_or(&0);
306
307        // min_commits以上の変更があるファイルのみ対象
308        if file_change_count < min_commits {
309            continue;
310        }
311
312        let coupling_percent = *co_change_count as f64 / file_change_count as f64;
313
314        // min_coupling以上の結合度のみ対象
315        if coupling_percent < min_coupling {
316            continue;
317        }
318
319        analyzed_files.insert(file.clone());
320
321        if coupling_percent >= 0.7 {
322            high_coupling_count += 1;
323        }
324
325        couplings.push(FileCoupling {
326            file: file.clone(),
327            coupled_file: coupled_file.clone(),
328            co_change_count: *co_change_count,
329            file_change_count,
330            coupling_percent,
331        });
332    }
333
334    // 5. 結合度の高い順にソート
335    couplings.sort_by(|a, b| {
336        b.coupling_percent
337            .partial_cmp(&a.coupling_percent)
338            .unwrap_or(std::cmp::Ordering::Equal)
339    });
340
341    // 高結合は重複カウントを避けるため2で割る(A→BとB→Aの両方がカウントされているため)
342    high_coupling_count /= 2;
343
344    ChangeCouplingAnalysis {
345        couplings,
346        high_coupling_count,
347        total_files_analyzed: analyzed_files.len(),
348    }
349}
350
351/// 著者別統計情報
352#[derive(Debug, Clone)]
353pub struct AuthorStats {
354    /// 著者名
355    pub name: String,
356    /// コミット数
357    pub commit_count: usize,
358    /// 追加行数
359    pub insertions: usize,
360    /// 削除行数
361    pub deletions: usize,
362    /// 最終コミット日時
363    pub last_commit: DateTime<Local>,
364}
365
366impl AuthorStats {
367    /// コミット割合を計算(パーセント)
368    pub fn commit_percentage(&self, total: usize) -> f64 {
369        if total == 0 {
370            0.0
371        } else {
372            (self.commit_count as f64 / total as f64) * 100.0
373        }
374    }
375}
376
377/// リポジトリ全体の統計情報
378#[derive(Debug, Clone, Default)]
379pub struct RepoStats {
380    /// 著者別統計(コミット数降順)
381    pub authors: Vec<AuthorStats>,
382    /// 総コミット数
383    pub total_commits: usize,
384    /// 総追加行数
385    pub total_insertions: usize,
386    /// 総削除行数
387    pub total_deletions: usize,
388}
389
390impl RepoStats {
391    /// 著者数を取得
392    pub fn author_count(&self) -> usize {
393        self.authors.len()
394    }
395}
396
397/// ファイル変更頻度のエントリ
398#[derive(Debug, Clone)]
399pub struct FileHeatmapEntry {
400    /// ファイルパス
401    pub path: String,
402    /// 変更回数
403    pub change_count: usize,
404    /// 最大変更回数(正規化用)
405    pub max_changes: usize,
406}
407
408impl FileHeatmapEntry {
409    /// ヒートレベルを計算(0.0〜1.0)
410    pub fn heat_level(&self) -> f64 {
411        if self.max_changes == 0 {
412            0.0
413        } else {
414            self.change_count as f64 / self.max_changes as f64
415        }
416    }
417
418    /// ヒートバーを生成(5段階)
419    pub fn heat_bar(&self) -> &'static str {
420        let level = self.heat_level();
421        if level >= 0.8 {
422            "█████"
423        } else if level >= 0.6 {
424            "████ "
425        } else if level >= 0.4 {
426            "███  "
427        } else if level >= 0.2 {
428            "██   "
429        } else {
430            "█    "
431        }
432    }
433}
434
435/// 集約レベル(ヒートマップ表示用)
436#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
437pub enum AggregationLevel {
438    /// ファイル単位(デフォルト)
439    #[default]
440    Files,
441    /// 浅い集約(2階層目まで、例: src/auth/)
442    Shallow,
443    /// 深い集約(1階層のみ、例: src/)
444    Deep,
445}
446
447impl AggregationLevel {
448    /// 次の集約レベルへ切り替え
449    pub fn next(&self) -> Self {
450        match self {
451            AggregationLevel::Files => AggregationLevel::Shallow,
452            AggregationLevel::Shallow => AggregationLevel::Deep,
453            AggregationLevel::Deep => AggregationLevel::Files,
454        }
455    }
456
457    /// 前の集約レベルへ切り替え
458    pub fn prev(&self) -> Self {
459        match self {
460            AggregationLevel::Files => AggregationLevel::Deep,
461            AggregationLevel::Shallow => AggregationLevel::Files,
462            AggregationLevel::Deep => AggregationLevel::Shallow,
463        }
464    }
465
466    /// 表示名を取得
467    pub fn display_name(&self) -> &'static str {
468        match self {
469            AggregationLevel::Files => "Files",
470            AggregationLevel::Shallow => "Directories (2 levels)",
471            AggregationLevel::Deep => "Directories (top level)",
472        }
473    }
474}
475
476/// ファイル変更ヒートマップ
477#[derive(Debug, Clone, Default)]
478pub struct FileHeatmap {
479    /// ファイル一覧(変更回数降順)
480    pub files: Vec<FileHeatmapEntry>,
481    /// 総ファイル数
482    pub total_files: usize,
483    /// 現在の集約レベル
484    pub aggregation_level: AggregationLevel,
485}
486
487impl FileHeatmap {
488    /// ファイル数を取得
489    pub fn file_count(&self) -> usize {
490        self.files.len()
491    }
492
493    /// 集約レベルを変更した新しいヒートマップを作成
494    pub fn with_aggregation(&self, level: AggregationLevel) -> FileHeatmap {
495        if level == AggregationLevel::Files {
496            // ファイルレベルはそのまま返す
497            return FileHeatmap {
498                files: self.files.clone(),
499                total_files: self.total_files,
500                aggregation_level: level,
501            };
502        }
503
504        // ディレクトリ単位で集約
505        let mut dir_counts: HashMap<String, usize> = HashMap::new();
506
507        for entry in &self.files {
508            let dir = extract_directory(&entry.path, level);
509            *dir_counts.entry(dir).or_insert(0) += entry.change_count;
510        }
511
512        let max_changes = dir_counts.values().copied().max().unwrap_or(0);
513
514        let mut files: Vec<FileHeatmapEntry> = dir_counts
515            .into_iter()
516            .map(|(path, change_count)| FileHeatmapEntry {
517                path,
518                change_count,
519                max_changes,
520            })
521            .collect();
522
523        // 変更回数降順でソート
524        files.sort_by(|a, b| b.change_count.cmp(&a.change_count));
525
526        FileHeatmap {
527            total_files: self.total_files,
528            files,
529            aggregation_level: level,
530        }
531    }
532}
533
534/// ディレクトリパスを抽出
535fn extract_directory(path: &str, level: AggregationLevel) -> String {
536    let parts: Vec<&str> = path.split('/').collect();
537    match level {
538        AggregationLevel::Files => path.to_string(),
539        AggregationLevel::Shallow => {
540            // src/auth/login.rs → src/auth/
541            if parts.len() > 2 {
542                format!("{}/{}/", parts[0], parts[1])
543            } else if parts.len() == 2 {
544                format!("{}/", parts[0])
545            } else {
546                path.to_string()
547            }
548        }
549        AggregationLevel::Deep => {
550            // src/auth/login.rs → src/
551            if parts.len() > 1 {
552                format!("{}/", parts[0])
553            } else {
554                path.to_string()
555            }
556        }
557    }
558}
559
560/// イベント一覧からファイル変更ヒートマップを計算
561pub fn calculate_file_heatmap(
562    events: &[&GitEvent],
563    get_files: impl Fn(&str) -> Option<Vec<String>>,
564) -> FileHeatmap {
565    let mut file_counts: HashMap<String, usize> = HashMap::new();
566
567    for event in events {
568        if let Some(files) = get_files(&event.short_hash) {
569            for file in files {
570                *file_counts.entry(file).or_insert(0) += 1;
571            }
572        }
573    }
574
575    let max_changes = file_counts.values().copied().max().unwrap_or(0);
576
577    let mut files: Vec<FileHeatmapEntry> = file_counts
578        .into_iter()
579        .map(|(path, change_count)| FileHeatmapEntry {
580            path,
581            change_count,
582            max_changes,
583        })
584        .collect();
585
586    // 変更回数降順でソート
587    files.sort_by(|a, b| b.change_count.cmp(&a.change_count));
588
589    let total_files = files.len();
590    FileHeatmap {
591        files,
592        total_files,
593        aggregation_level: AggregationLevel::Files,
594    }
595}
596
597/// 活動タイムライン(曜日×時間帯のヒートマップ)
598#[derive(Debug, Clone, Default)]
599pub struct ActivityTimeline {
600    /// 7x24のグリッド(曜日 x 時間帯)
601    /// grid[曜日][時間] = コミット数
602    /// 曜日: 0=月, 1=火, 2=水, 3=木, 4=金, 5=土, 6=日
603    pub grid: [[usize; 24]; 7],
604    /// 総コミット数
605    pub total_commits: usize,
606    /// ピーク曜日(0-6)
607    pub peak_day: usize,
608    /// ピーク時間(0-23)
609    pub peak_hour: usize,
610    /// ピーク時のコミット数
611    pub peak_count: usize,
612    /// 最大コミット数(正規化用)
613    pub max_count: usize,
614}
615
616impl ActivityTimeline {
617    /// 曜日名を取得
618    pub fn day_name(day: usize) -> &'static str {
619        match day {
620            0 => "Mon",
621            1 => "Tue",
622            2 => "Wed",
623            3 => "Thu",
624            4 => "Fri",
625            5 => "Sat",
626            6 => "Sun",
627            _ => "???",
628        }
629    }
630
631    /// ヒートレベルを計算(0.0〜1.0)
632    pub fn heat_level(&self, day: usize, hour: usize) -> f64 {
633        if self.max_count == 0 {
634            0.0
635        } else {
636            self.grid[day][hour] as f64 / self.max_count as f64
637        }
638    }
639
640    /// ヒートマップの文字を取得
641    pub fn heat_char(level: f64) -> &'static str {
642        if level >= 0.8 {
643            "██"
644        } else if level >= 0.6 {
645            "▓▓"
646        } else if level >= 0.4 {
647            "▒▒"
648        } else if level >= 0.2 {
649            "░░"
650        } else if level > 0.0 {
651            "··"
652        } else {
653            "  "
654        }
655    }
656
657    /// ピーク時間帯の文字列を取得
658    pub fn peak_summary(&self) -> String {
659        if self.peak_count == 0 {
660            "No activity".to_string()
661        } else {
662            format!(
663                "{} {:02}:00-{:02}:00 ({} commits)",
664                Self::day_name(self.peak_day),
665                self.peak_hour,
666                (self.peak_hour + 1) % 24,
667                self.peak_count
668            )
669        }
670    }
671}
672
673/// イベント一覧から活動タイムラインを計算
674pub fn calculate_activity_timeline(events: &[&GitEvent]) -> ActivityTimeline {
675    use chrono::Datelike;
676    use chrono::Timelike;
677
678    let mut timeline = ActivityTimeline {
679        total_commits: events.len(),
680        ..Default::default()
681    };
682
683    for event in events {
684        // chrono::Weekdayは月曜=0...日曜=6
685        let day = event.timestamp.weekday().num_days_from_monday() as usize;
686        let hour = event.timestamp.hour() as usize;
687
688        timeline.grid[day][hour] += 1;
689    }
690
691    // ピークを計算
692    let mut max_count = 0usize;
693    for (day, hours) in timeline.grid.iter().enumerate() {
694        for (hour, &count) in hours.iter().enumerate() {
695            if count > max_count {
696                max_count = count;
697                timeline.peak_day = day;
698                timeline.peak_hour = hour;
699                timeline.peak_count = count;
700            }
701        }
702    }
703    timeline.max_count = max_count;
704
705    timeline
706}
707
708/// コードオーナーシップのエントリ
709#[derive(Debug, Clone)]
710pub struct CodeOwnershipEntry {
711    /// パス(ディレクトリまたはファイル)
712    pub path: String,
713    /// 主要著者
714    pub primary_author: String,
715    /// 主要著者のコミット数
716    pub primary_commits: usize,
717    /// 総コミット数
718    pub total_commits: usize,
719    /// 深さ(インデント用)
720    pub depth: usize,
721    /// ディレクトリかどうか
722    pub is_directory: bool,
723}
724
725impl CodeOwnershipEntry {
726    /// 主要著者の割合を計算(パーセント)
727    pub fn ownership_percentage(&self) -> f64 {
728        if self.total_commits == 0 {
729            0.0
730        } else {
731            (self.primary_commits as f64 / self.total_commits as f64) * 100.0
732        }
733    }
734}
735
736/// コードオーナーシップ分析結果
737#[derive(Debug, Clone, Default)]
738pub struct CodeOwnership {
739    /// エントリ一覧(階層順)
740    pub entries: Vec<CodeOwnershipEntry>,
741    /// 総ファイル数
742    pub total_files: usize,
743}
744
745impl CodeOwnership {
746    /// エントリ数を取得
747    pub fn entry_count(&self) -> usize {
748        self.entries.len()
749    }
750}
751
752/// イベント一覧からコードオーナーシップを計算
753pub fn calculate_ownership(
754    events: &[&GitEvent],
755    get_files: impl Fn(&str) -> Option<Vec<String>>,
756) -> CodeOwnership {
757    // ファイルごとの著者別コミット数を集計
758    let mut file_author_counts: HashMap<String, HashMap<String, usize>> = HashMap::new();
759
760    for event in events {
761        if let Some(files) = get_files(&event.short_hash) {
762            for file in files {
763                let author_counts = file_author_counts.entry(file).or_default();
764                *author_counts.entry(event.author.clone()).or_insert(0) += 1;
765            }
766        }
767    }
768
769    // ディレクトリごとの著者別コミット数を集計
770    let mut dir_author_counts: HashMap<String, HashMap<String, usize>> = HashMap::new();
771
772    for (file_path, author_counts) in &file_author_counts {
773        // ファイルパスからディレクトリパスを抽出
774        let parts: Vec<&str> = file_path.split('/').collect();
775        for i in 1..parts.len() {
776            let dir_path = parts[..i].join("/");
777            let dir_counts = dir_author_counts.entry(dir_path).or_default();
778            for (author, count) in author_counts {
779                *dir_counts.entry(author.clone()).or_insert(0) += count;
780            }
781        }
782    }
783
784    // エントリを生成
785    let mut entries = Vec::new();
786
787    // ディレクトリを階層順にソート
788    let mut dir_paths: Vec<String> = dir_author_counts.keys().cloned().collect();
789    dir_paths.sort();
790
791    for dir_path in dir_paths {
792        let author_counts = &dir_author_counts[&dir_path];
793        let (primary_author, primary_commits) = author_counts
794            .iter()
795            .max_by_key(|(_, c)| *c)
796            .map(|(a, c)| (a.clone(), *c))
797            .unwrap_or_default();
798        let total_commits: usize = author_counts.values().sum();
799        let depth = dir_path.matches('/').count();
800
801        entries.push(CodeOwnershipEntry {
802            path: dir_path,
803            primary_author,
804            primary_commits,
805            total_commits,
806            depth,
807            is_directory: true,
808        });
809    }
810
811    // ファイルも追加(ディレクトリの後に)
812    let mut file_paths: Vec<String> = file_author_counts.keys().cloned().collect();
813    file_paths.sort();
814
815    for file_path in file_paths {
816        let author_counts = &file_author_counts[&file_path];
817        let (primary_author, primary_commits) = author_counts
818            .iter()
819            .max_by_key(|(_, c)| *c)
820            .map(|(a, c)| (a.clone(), *c))
821            .unwrap_or_default();
822        let total_commits: usize = author_counts.values().sum();
823        let depth = file_path.matches('/').count();
824
825        entries.push(CodeOwnershipEntry {
826            path: file_path,
827            primary_author,
828            primary_commits,
829            total_commits,
830            depth,
831            is_directory: false,
832        });
833    }
834
835    // ディレクトリとファイルを交互に表示するため、パスでソート
836    entries.sort_by(|a, b| a.path.cmp(&b.path));
837
838    let total_files = file_author_counts.len();
839    CodeOwnership {
840        entries,
841        total_files,
842    }
843}
844
845/// イベント一覧から統計情報を計算
846pub fn calculate_stats(events: &[&GitEvent]) -> RepoStats {
847    let mut author_map: HashMap<String, AuthorStats> = HashMap::new();
848    let mut total_insertions = 0usize;
849    let mut total_deletions = 0usize;
850
851    for event in events {
852        total_insertions += event.files_added;
853        total_deletions += event.files_deleted;
854
855        let entry = author_map
856            .entry(event.author.clone())
857            .or_insert(AuthorStats {
858                name: event.author.clone(),
859                commit_count: 0,
860                insertions: 0,
861                deletions: 0,
862                last_commit: event.timestamp,
863            });
864
865        entry.commit_count += 1;
866        entry.insertions += event.files_added;
867        entry.deletions += event.files_deleted;
868
869        // 最新のコミット日時を更新
870        if event.timestamp > entry.last_commit {
871            entry.last_commit = event.timestamp;
872        }
873    }
874
875    // コミット数降順でソート
876    let mut authors: Vec<AuthorStats> = author_map.into_values().collect();
877    authors.sort_by(|a, b| b.commit_count.cmp(&a.commit_count));
878
879    RepoStats {
880        authors,
881        total_commits: events.len(),
882        total_insertions,
883        total_deletions,
884    }
885}
886
887#[cfg(test)]
888#[allow(clippy::useless_vec)]
889mod tests {
890    use super::*;
891    use chrono::Local;
892
893    fn create_test_event(author: &str, insertions: usize, deletions: usize) -> GitEvent {
894        GitEvent::commit(
895            "abc1234".to_string(),
896            "test commit".to_string(),
897            author.to_string(),
898            Local::now(),
899            insertions,
900            deletions,
901        )
902    }
903
904    #[test]
905    fn test_calculate_stats_empty() {
906        let stats = calculate_stats(&[]);
907        assert_eq!(stats.total_commits, 0);
908        assert_eq!(stats.authors.len(), 0);
909    }
910
911    #[test]
912    fn test_calculate_stats_single_author() {
913        let events = vec![
914            create_test_event("Alice", 10, 5),
915            create_test_event("Alice", 20, 10),
916        ];
917        let refs: Vec<&GitEvent> = events.iter().collect();
918        let stats = calculate_stats(&refs);
919
920        assert_eq!(stats.total_commits, 2);
921        assert_eq!(stats.authors.len(), 1);
922        assert_eq!(stats.authors[0].name, "Alice");
923        assert_eq!(stats.authors[0].commit_count, 2);
924        assert_eq!(stats.authors[0].insertions, 30);
925        assert_eq!(stats.authors[0].deletions, 15);
926    }
927
928    #[test]
929    fn test_calculate_stats_multiple_authors() {
930        let events = vec![
931            create_test_event("Alice", 10, 5),
932            create_test_event("Bob", 5, 2),
933            create_test_event("Alice", 20, 10),
934            create_test_event("Bob", 15, 8),
935            create_test_event("Bob", 10, 5),
936        ];
937        let refs: Vec<&GitEvent> = events.iter().collect();
938        let stats = calculate_stats(&refs);
939
940        assert_eq!(stats.total_commits, 5);
941        assert_eq!(stats.authors.len(), 2);
942
943        // Bobが3コミットで最多なので先頭
944        assert_eq!(stats.authors[0].name, "Bob");
945        assert_eq!(stats.authors[0].commit_count, 3);
946
947        // Aliceが2コミット
948        assert_eq!(stats.authors[1].name, "Alice");
949        assert_eq!(stats.authors[1].commit_count, 2);
950    }
951
952    #[test]
953    fn test_calculate_stats_totals() {
954        let events = vec![
955            create_test_event("Alice", 10, 5),
956            create_test_event("Bob", 20, 10),
957        ];
958        let refs: Vec<&GitEvent> = events.iter().collect();
959        let stats = calculate_stats(&refs);
960
961        assert_eq!(stats.total_insertions, 30);
962        assert_eq!(stats.total_deletions, 15);
963    }
964
965    #[test]
966    fn test_author_stats_commit_percentage() {
967        let author = AuthorStats {
968            name: "Alice".to_string(),
969            commit_count: 25,
970            insertions: 0,
971            deletions: 0,
972            last_commit: Local::now(),
973        };
974
975        assert!((author.commit_percentage(100) - 25.0).abs() < 0.01);
976        assert!((author.commit_percentage(50) - 50.0).abs() < 0.01);
977    }
978
979    #[test]
980    fn test_author_stats_commit_percentage_zero() {
981        let author = AuthorStats {
982            name: "Alice".to_string(),
983            commit_count: 10,
984            insertions: 0,
985            deletions: 0,
986            last_commit: Local::now(),
987        };
988
989        assert_eq!(author.commit_percentage(0), 0.0);
990    }
991
992    #[test]
993    fn test_repo_stats_author_count() {
994        let events = vec![
995            create_test_event("Alice", 10, 5),
996            create_test_event("Bob", 5, 2),
997            create_test_event("Charlie", 15, 8),
998        ];
999        let refs: Vec<&GitEvent> = events.iter().collect();
1000        let stats = calculate_stats(&refs);
1001
1002        assert_eq!(stats.author_count(), 3);
1003    }
1004
1005    // ===== ヒートマップのテスト =====
1006
1007    fn create_test_event_with_hash(hash: &str) -> GitEvent {
1008        GitEvent::commit(
1009            hash.to_string(),
1010            "test commit".to_string(),
1011            "author".to_string(),
1012            Local::now(),
1013            10,
1014            5,
1015        )
1016    }
1017
1018    #[test]
1019    fn test_calculate_file_heatmap_empty() {
1020        let events: Vec<&GitEvent> = vec![];
1021        let heatmap = calculate_file_heatmap(&events, |_| None);
1022        assert_eq!(heatmap.file_count(), 0);
1023    }
1024
1025    #[test]
1026    fn test_calculate_file_heatmap_single_file() {
1027        let events = vec![
1028            create_test_event_with_hash("abc1234"),
1029            create_test_event_with_hash("def5678"),
1030        ];
1031        let refs: Vec<&GitEvent> = events.iter().collect();
1032        let heatmap = calculate_file_heatmap(&refs, |_| Some(vec!["src/main.rs".to_string()]));
1033
1034        assert_eq!(heatmap.file_count(), 1);
1035        assert_eq!(heatmap.files[0].path, "src/main.rs");
1036        assert_eq!(heatmap.files[0].change_count, 2);
1037    }
1038
1039    #[test]
1040    fn test_calculate_file_heatmap_multiple_files() {
1041        let events = vec![
1042            create_test_event_with_hash("abc1234"),
1043            create_test_event_with_hash("def5678"),
1044            create_test_event_with_hash("ghi9012"),
1045        ];
1046        let refs: Vec<&GitEvent> = events.iter().collect();
1047        let heatmap = calculate_file_heatmap(&refs, |hash| match hash {
1048            "abc1234" => Some(vec!["src/a.rs".to_string(), "src/b.rs".to_string()]),
1049            "def5678" => Some(vec!["src/a.rs".to_string()]),
1050            "ghi9012" => Some(vec!["src/a.rs".to_string(), "src/c.rs".to_string()]),
1051            _ => None,
1052        });
1053
1054        assert_eq!(heatmap.file_count(), 3);
1055        // src/a.rsが3回で最多
1056        assert_eq!(heatmap.files[0].path, "src/a.rs");
1057        assert_eq!(heatmap.files[0].change_count, 3);
1058    }
1059
1060    #[test]
1061    fn test_file_heatmap_entry_heat_level() {
1062        let entry = FileHeatmapEntry {
1063            path: "test.rs".to_string(),
1064            change_count: 5,
1065            max_changes: 10,
1066        };
1067        assert!((entry.heat_level() - 0.5).abs() < 0.01);
1068    }
1069
1070    #[test]
1071    fn test_file_heatmap_entry_heat_bar() {
1072        let entry_high = FileHeatmapEntry {
1073            path: "test.rs".to_string(),
1074            change_count: 10,
1075            max_changes: 10,
1076        };
1077        assert_eq!(entry_high.heat_bar(), "█████");
1078
1079        let entry_low = FileHeatmapEntry {
1080            path: "test.rs".to_string(),
1081            change_count: 1,
1082            max_changes: 10,
1083        };
1084        assert_eq!(entry_low.heat_bar(), "█    ");
1085    }
1086
1087    // ===== AggregationLevel テスト =====
1088
1089    #[test]
1090    fn test_aggregation_level_next() {
1091        assert_eq!(AggregationLevel::Files.next(), AggregationLevel::Shallow);
1092        assert_eq!(AggregationLevel::Shallow.next(), AggregationLevel::Deep);
1093        assert_eq!(AggregationLevel::Deep.next(), AggregationLevel::Files);
1094    }
1095
1096    #[test]
1097    fn test_aggregation_level_prev() {
1098        assert_eq!(AggregationLevel::Files.prev(), AggregationLevel::Deep);
1099        assert_eq!(AggregationLevel::Shallow.prev(), AggregationLevel::Files);
1100        assert_eq!(AggregationLevel::Deep.prev(), AggregationLevel::Shallow);
1101    }
1102
1103    #[test]
1104    fn test_aggregation_level_display_name() {
1105        assert_eq!(AggregationLevel::Files.display_name(), "Files");
1106        assert!(AggregationLevel::Shallow
1107            .display_name()
1108            .contains("2 levels"));
1109        assert!(AggregationLevel::Deep.display_name().contains("top level"));
1110    }
1111
1112    #[test]
1113    fn test_heatmap_with_aggregation_shallow() {
1114        let heatmap = FileHeatmap {
1115            files: vec![
1116                FileHeatmapEntry {
1117                    path: "src/auth/login.rs".to_string(),
1118                    change_count: 10,
1119                    max_changes: 10,
1120                },
1121                FileHeatmapEntry {
1122                    path: "src/auth/token.rs".to_string(),
1123                    change_count: 5,
1124                    max_changes: 10,
1125                },
1126                FileHeatmapEntry {
1127                    path: "src/api/user.rs".to_string(),
1128                    change_count: 3,
1129                    max_changes: 10,
1130                },
1131            ],
1132            total_files: 3,
1133            aggregation_level: AggregationLevel::Files,
1134        };
1135
1136        let aggregated = heatmap.with_aggregation(AggregationLevel::Shallow);
1137        assert_eq!(aggregated.aggregation_level, AggregationLevel::Shallow);
1138        assert_eq!(aggregated.files.len(), 2); // src/auth/ と src/api/
1139
1140        // src/auth/が最多(10+5=15)
1141        assert_eq!(aggregated.files[0].path, "src/auth/");
1142        assert_eq!(aggregated.files[0].change_count, 15);
1143    }
1144
1145    #[test]
1146    fn test_heatmap_with_aggregation_deep() {
1147        let heatmap = FileHeatmap {
1148            files: vec![
1149                FileHeatmapEntry {
1150                    path: "src/auth/login.rs".to_string(),
1151                    change_count: 10,
1152                    max_changes: 10,
1153                },
1154                FileHeatmapEntry {
1155                    path: "src/api/user.rs".to_string(),
1156                    change_count: 5,
1157                    max_changes: 10,
1158                },
1159                FileHeatmapEntry {
1160                    path: "tests/test.rs".to_string(),
1161                    change_count: 3,
1162                    max_changes: 10,
1163                },
1164            ],
1165            total_files: 3,
1166            aggregation_level: AggregationLevel::Files,
1167        };
1168
1169        let aggregated = heatmap.with_aggregation(AggregationLevel::Deep);
1170        assert_eq!(aggregated.aggregation_level, AggregationLevel::Deep);
1171        assert_eq!(aggregated.files.len(), 2); // src/ と tests/
1172
1173        // src/が最多(10+5=15)
1174        assert_eq!(aggregated.files[0].path, "src/");
1175        assert_eq!(aggregated.files[0].change_count, 15);
1176    }
1177
1178    // ===== Impact Score テスト =====
1179
1180    fn create_test_event_with_hash_changes(
1181        hash: &str,
1182        author: &str,
1183        insertions: usize,
1184        deletions: usize,
1185    ) -> GitEvent {
1186        GitEvent::commit(
1187            hash.to_string(),
1188            "test commit".to_string(),
1189            author.to_string(),
1190            Local::now(),
1191            insertions,
1192            deletions,
1193        )
1194    }
1195
1196    #[test]
1197    fn test_calculate_impact_scores_empty() {
1198        let events: Vec<&GitEvent> = vec![];
1199        let heatmap = FileHeatmap::default();
1200        let analysis = calculate_impact_scores(&events, |_| None, &heatmap);
1201        assert_eq!(analysis.total_commits, 0);
1202        assert_eq!(analysis.commits.len(), 0);
1203        assert_eq!(analysis.avg_score, 0.0);
1204    }
1205
1206    #[test]
1207    fn test_calculate_impact_scores_single_commit() {
1208        let events = vec![create_test_event_with_hash_changes(
1209            "abc1234", "Alice", 100, 50,
1210        )];
1211        let refs: Vec<&GitEvent> = events.iter().collect();
1212        let heatmap = FileHeatmap::default();
1213
1214        let analysis =
1215            calculate_impact_scores(&refs, |_| Some(vec!["src/main.rs".to_string()]), &heatmap);
1216
1217        assert_eq!(analysis.total_commits, 1);
1218        assert_eq!(analysis.commits.len(), 1);
1219
1220        let commit = &analysis.commits[0];
1221        assert_eq!(commit.commit_hash, "abc1234");
1222        assert_eq!(commit.files_changed, 1);
1223        assert_eq!(commit.insertions, 100);
1224        assert_eq!(commit.deletions, 50);
1225    }
1226
1227    #[test]
1228    fn test_calculate_impact_scores_file_score() {
1229        // 50ファイル変更 → file_score = 0.4
1230        let events = vec![create_test_event_with_hash_changes(
1231            "abc1234", "Alice", 10, 5,
1232        )];
1233        let refs: Vec<&GitEvent> = events.iter().collect();
1234        let heatmap = FileHeatmap::default();
1235
1236        // 50ファイルを返す
1237        let files: Vec<String> = (0..50).map(|i| format!("file{}.rs", i)).collect();
1238        let analysis = calculate_impact_scores(&refs, |_| Some(files.clone()), &heatmap);
1239
1240        let commit = &analysis.commits[0];
1241        assert!((commit.file_score - 0.4).abs() < 0.01);
1242    }
1243
1244    #[test]
1245    fn test_calculate_impact_scores_change_score() {
1246        // 500行変更 → change_score = 0.4
1247        let events = vec![create_test_event_with_hash_changes(
1248            "abc1234", "Alice", 250, 250,
1249        )];
1250        let refs: Vec<&GitEvent> = events.iter().collect();
1251        let heatmap = FileHeatmap::default();
1252
1253        let analysis =
1254            calculate_impact_scores(&refs, |_| Some(vec!["src/main.rs".to_string()]), &heatmap);
1255
1256        let commit = &analysis.commits[0];
1257        assert!((commit.change_score - 0.4).abs() < 0.01);
1258    }
1259
1260    #[test]
1261    fn test_calculate_impact_scores_sorted_by_score_desc() {
1262        let events = vec![
1263            create_test_event_with_hash_changes("low", "Alice", 10, 5), // 低スコア
1264            create_test_event_with_hash_changes("high", "Bob", 400, 100), // 高スコア
1265            create_test_event_with_hash_changes("medium", "Carol", 100, 50), // 中スコア
1266        ];
1267        let refs: Vec<&GitEvent> = events.iter().collect();
1268        let heatmap = FileHeatmap::default();
1269
1270        let analysis =
1271            calculate_impact_scores(&refs, |_| Some(vec!["src/main.rs".to_string()]), &heatmap);
1272
1273        assert_eq!(analysis.commits.len(), 3);
1274        // スコア降順でソートされている
1275        assert_eq!(analysis.commits[0].commit_hash, "high");
1276        assert_eq!(analysis.commits[1].commit_hash, "medium");
1277        assert_eq!(analysis.commits[2].commit_hash, "low");
1278    }
1279
1280    #[test]
1281    fn test_commit_impact_score_color_high() {
1282        let commit = CommitImpactScore {
1283            commit_hash: "abc".to_string(),
1284            commit_message: "test".to_string(),
1285            author: "Alice".to_string(),
1286            date: Local::now(),
1287            files_changed: 0,
1288            insertions: 0,
1289            deletions: 0,
1290            score: 0.8,
1291            file_score: 0.0,
1292            change_score: 0.0,
1293            heat_score: 0.0,
1294        };
1295        assert_eq!(commit.score_color(), "red");
1296    }
1297
1298    #[test]
1299    fn test_commit_impact_score_color_medium() {
1300        let commit = CommitImpactScore {
1301            commit_hash: "abc".to_string(),
1302            commit_message: "test".to_string(),
1303            author: "Alice".to_string(),
1304            date: Local::now(),
1305            files_changed: 0,
1306            insertions: 0,
1307            deletions: 0,
1308            score: 0.5,
1309            file_score: 0.0,
1310            change_score: 0.0,
1311            heat_score: 0.0,
1312        };
1313        assert_eq!(commit.score_color(), "yellow");
1314    }
1315
1316    #[test]
1317    fn test_commit_impact_score_color_low() {
1318        let commit = CommitImpactScore {
1319            commit_hash: "abc".to_string(),
1320            commit_message: "test".to_string(),
1321            author: "Alice".to_string(),
1322            date: Local::now(),
1323            files_changed: 0,
1324            insertions: 0,
1325            deletions: 0,
1326            score: 0.2,
1327            file_score: 0.0,
1328            change_score: 0.0,
1329            heat_score: 0.0,
1330        };
1331        assert_eq!(commit.score_color(), "green");
1332    }
1333
1334    #[test]
1335    fn test_commit_impact_score_bar() {
1336        let mut commit = CommitImpactScore {
1337            commit_hash: "abc".to_string(),
1338            commit_message: "test".to_string(),
1339            author: "Alice".to_string(),
1340            date: Local::now(),
1341            files_changed: 0,
1342            insertions: 0,
1343            deletions: 0,
1344            score: 0.0,
1345            file_score: 0.0,
1346            change_score: 0.0,
1347            heat_score: 0.0,
1348        };
1349
1350        commit.score = 0.9;
1351        assert_eq!(commit.score_bar(), "█████");
1352
1353        commit.score = 0.7;
1354        assert_eq!(commit.score_bar(), "████ ");
1355
1356        commit.score = 0.5;
1357        assert_eq!(commit.score_bar(), "███  ");
1358
1359        commit.score = 0.3;
1360        assert_eq!(commit.score_bar(), "██   ");
1361
1362        commit.score = 0.1;
1363        assert_eq!(commit.score_bar(), "█    ");
1364    }
1365
1366    #[test]
1367    fn test_calculate_impact_scores_high_impact_count() {
1368        let events = vec![
1369            create_test_event_with_hash_changes("a", "Alice", 400, 100), // high (score >= 0.7)
1370            create_test_event_with_hash_changes("b", "Bob", 300, 100),   // medium
1371            create_test_event_with_hash_changes("c", "Carol", 10, 5),    // low
1372        ];
1373        let refs: Vec<&GitEvent> = events.iter().collect();
1374        let heatmap = FileHeatmap::default();
1375
1376        // 多くのファイルを変更して高スコアに
1377        let analysis = calculate_impact_scores(
1378            &refs,
1379            |hash| {
1380                if hash == "a" {
1381                    Some((0..50).map(|i| format!("file{}.rs", i)).collect())
1382                } else {
1383                    Some(vec!["file.rs".to_string()])
1384                }
1385            },
1386            &heatmap,
1387        );
1388
1389        assert!(analysis.high_impact_count >= 1);
1390    }
1391
1392    // ===== Change Coupling テスト =====
1393
1394    #[test]
1395    fn test_calculate_change_coupling_empty() {
1396        let events: Vec<&GitEvent> = vec![];
1397        let analysis = calculate_change_coupling(&events, |_| None, 5, 0.3);
1398        assert_eq!(analysis.coupling_count(), 0);
1399        assert_eq!(analysis.high_coupling_count, 0);
1400        assert_eq!(analysis.total_files_analyzed, 0);
1401    }
1402
1403    #[test]
1404    fn test_calculate_change_coupling_single_file() {
1405        // 単一ファイルのコミットではペアが作れないので結合なし
1406        let events = vec![
1407            create_test_event_with_hash("commit1"),
1408            create_test_event_with_hash("commit2"),
1409            create_test_event_with_hash("commit3"),
1410            create_test_event_with_hash("commit4"),
1411            create_test_event_with_hash("commit5"),
1412        ];
1413        let refs: Vec<&GitEvent> = events.iter().collect();
1414
1415        let analysis =
1416            calculate_change_coupling(&refs, |_| Some(vec!["src/main.rs".to_string()]), 1, 0.0);
1417        assert_eq!(analysis.coupling_count(), 0);
1418    }
1419
1420    #[test]
1421    fn test_calculate_change_coupling_pair() {
1422        // 2ファイルが常に一緒に変更される場合
1423        let events = vec![
1424            create_test_event_with_hash("commit1"),
1425            create_test_event_with_hash("commit2"),
1426            create_test_event_with_hash("commit3"),
1427            create_test_event_with_hash("commit4"),
1428            create_test_event_with_hash("commit5"),
1429        ];
1430        let refs: Vec<&GitEvent> = events.iter().collect();
1431
1432        let analysis = calculate_change_coupling(
1433            &refs,
1434            |_| Some(vec!["src/app.rs".to_string(), "src/ui.rs".to_string()]),
1435            5,
1436            0.3,
1437        );
1438
1439        // A→B と B→A の両方が記録される
1440        assert_eq!(analysis.coupling_count(), 2);
1441
1442        // 両方とも100%の結合度
1443        for coupling in &analysis.couplings {
1444            assert!((coupling.coupling_percent - 1.0).abs() < 0.01);
1445            assert_eq!(coupling.co_change_count, 5);
1446            assert_eq!(coupling.file_change_count, 5);
1447        }
1448    }
1449
1450    #[test]
1451    fn test_calculate_change_coupling_partial() {
1452        // 部分的に一緒に変更されるファイル
1453        let events = vec![
1454            create_test_event_with_hash("commit1"),
1455            create_test_event_with_hash("commit2"),
1456            create_test_event_with_hash("commit3"),
1457            create_test_event_with_hash("commit4"),
1458            create_test_event_with_hash("commit5"),
1459            create_test_event_with_hash("commit6"),
1460            create_test_event_with_hash("commit7"),
1461            create_test_event_with_hash("commit8"),
1462            create_test_event_with_hash("commit9"),
1463            create_test_event_with_hash("commit10"),
1464        ];
1465        let refs: Vec<&GitEvent> = events.iter().collect();
1466
1467        let analysis = calculate_change_coupling(
1468            &refs,
1469            |hash| {
1470                // 最初の8コミットはapp.rsとui.rsを変更
1471                // 残り2コミットはapp.rsのみ変更
1472                if hash == "commit9" || hash == "commit10" {
1473                    Some(vec!["src/app.rs".to_string()])
1474                } else {
1475                    Some(vec!["src/app.rs".to_string(), "src/ui.rs".to_string()])
1476                }
1477            },
1478            5,
1479            0.3,
1480        );
1481
1482        // app.rs→ui.rsの結合度は80%(8/10)
1483        let app_to_ui = analysis
1484            .couplings
1485            .iter()
1486            .find(|c| c.file == "src/app.rs" && c.coupled_file == "src/ui.rs");
1487        assert!(app_to_ui.is_some());
1488        let coupling = app_to_ui.unwrap();
1489        assert!((coupling.coupling_percent - 0.8).abs() < 0.01);
1490
1491        // ui.rs→app.rsの結合度は100%(8/8)
1492        let ui_to_app = analysis
1493            .couplings
1494            .iter()
1495            .find(|c| c.file == "src/ui.rs" && c.coupled_file == "src/app.rs");
1496        assert!(ui_to_app.is_some());
1497        let coupling = ui_to_app.unwrap();
1498        assert!((coupling.coupling_percent - 1.0).abs() < 0.01);
1499    }
1500
1501    #[test]
1502    fn test_calculate_change_coupling_min_commits_filter() {
1503        let events = vec![
1504            create_test_event_with_hash("commit1"),
1505            create_test_event_with_hash("commit2"),
1506            create_test_event_with_hash("commit3"),
1507        ];
1508        let refs: Vec<&GitEvent> = events.iter().collect();
1509
1510        // min_commits=5なので、3コミットのファイルは除外される
1511        let analysis = calculate_change_coupling(
1512            &refs,
1513            |_| Some(vec!["src/app.rs".to_string(), "src/ui.rs".to_string()]),
1514            5,
1515            0.3,
1516        );
1517
1518        assert_eq!(analysis.coupling_count(), 0);
1519    }
1520
1521    #[test]
1522    fn test_calculate_change_coupling_min_coupling_filter() {
1523        let events = vec![
1524            create_test_event_with_hash("commit1"),
1525            create_test_event_with_hash("commit2"),
1526            create_test_event_with_hash("commit3"),
1527            create_test_event_with_hash("commit4"),
1528            create_test_event_with_hash("commit5"),
1529        ];
1530        let refs: Vec<&GitEvent> = events.iter().collect();
1531
1532        let analysis = calculate_change_coupling(
1533            &refs,
1534            |hash| {
1535                // commit1だけapp.rsとui.rsを変更(20%の結合度)
1536                if hash == "commit1" {
1537                    Some(vec!["src/app.rs".to_string(), "src/ui.rs".to_string()])
1538                } else {
1539                    Some(vec!["src/app.rs".to_string()])
1540                }
1541            },
1542            1,
1543            0.3, // 30%未満は除外
1544        );
1545
1546        // app.rs→ui.rsは20%なので除外される
1547        let app_to_ui = analysis
1548            .couplings
1549            .iter()
1550            .find(|c| c.file == "src/app.rs" && c.coupled_file == "src/ui.rs");
1551        assert!(app_to_ui.is_none());
1552    }
1553
1554    #[test]
1555    fn test_calculate_change_coupling_high_coupling_count() {
1556        let events = vec![
1557            create_test_event_with_hash("commit1"),
1558            create_test_event_with_hash("commit2"),
1559            create_test_event_with_hash("commit3"),
1560            create_test_event_with_hash("commit4"),
1561            create_test_event_with_hash("commit5"),
1562        ];
1563        let refs: Vec<&GitEvent> = events.iter().collect();
1564
1565        // 100%の結合度(高結合)
1566        let analysis = calculate_change_coupling(
1567            &refs,
1568            |_| Some(vec!["src/app.rs".to_string(), "src/ui.rs".to_string()]),
1569            5,
1570            0.3,
1571        );
1572
1573        // A→BとB→Aで2つだが、重複を避けて1としてカウント
1574        assert_eq!(analysis.high_coupling_count, 1);
1575    }
1576
1577    #[test]
1578    fn test_file_coupling_bar() {
1579        let coupling = FileCoupling {
1580            file: "src/app.rs".to_string(),
1581            coupled_file: "src/ui.rs".to_string(),
1582            co_change_count: 8,
1583            file_change_count: 10,
1584            coupling_percent: 0.8,
1585        };
1586
1587        // 80%なので8個の█と2個の░
1588        assert_eq!(coupling.coupling_bar(), "[████████░░]");
1589    }
1590
1591    #[test]
1592    fn test_change_coupling_grouped_by_file() {
1593        let analysis = ChangeCouplingAnalysis {
1594            couplings: vec![
1595                FileCoupling {
1596                    file: "src/app.rs".to_string(),
1597                    coupled_file: "src/ui.rs".to_string(),
1598                    co_change_count: 8,
1599                    file_change_count: 10,
1600                    coupling_percent: 0.8,
1601                },
1602                FileCoupling {
1603                    file: "src/app.rs".to_string(),
1604                    coupled_file: "src/main.rs".to_string(),
1605                    co_change_count: 5,
1606                    file_change_count: 10,
1607                    coupling_percent: 0.5,
1608                },
1609                FileCoupling {
1610                    file: "src/git.rs".to_string(),
1611                    coupled_file: "src/filter.rs".to_string(),
1612                    co_change_count: 3,
1613                    file_change_count: 5,
1614                    coupling_percent: 0.6,
1615                },
1616            ],
1617            high_coupling_count: 1,
1618            total_files_analyzed: 5,
1619        };
1620
1621        let grouped = analysis.grouped_by_file();
1622        assert_eq!(grouped.len(), 2);
1623
1624        // app.rs(10コミット)が先頭
1625        assert_eq!(grouped[0].0, "src/app.rs");
1626        assert_eq!(grouped[0].1.len(), 2);
1627
1628        // git.rs(5コミット)が次
1629        assert_eq!(grouped[1].0, "src/git.rs");
1630        assert_eq!(grouped[1].1.len(), 1);
1631    }
1632}