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// Quality Score
12// =============================================================================
13
14/// コミット品質スコア
15#[derive(Debug, Clone)]
16pub struct CommitQualityScore {
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.3)
34    pub message_score: f64,
35    /// サイズ適切性要素 (0.0〜0.25)
36    pub size_score: f64,
37    /// テスト包含要素 (0.0〜0.25)
38    pub test_score: f64,
39    /// 原子性要素 (0.0〜0.2)
40    pub atomicity_score: f64,
41}
42
43impl CommitQualityScore {
44    /// スコアに応じた色を取得
45    pub fn score_color(&self) -> &'static str {
46        if self.score >= 0.6 {
47            "green" // Excellent/Good
48        } else if self.score >= 0.4 {
49            "yellow" // Fair
50        } else {
51            "red" // Poor
52        }
53    }
54
55    /// スコアバーを生成(5段階)
56    pub fn score_bar(&self) -> &'static str {
57        if self.score >= 0.8 {
58            "█████"
59        } else if self.score >= 0.6 {
60            "████ "
61        } else if self.score >= 0.4 {
62            "███  "
63        } else if self.score >= 0.2 {
64            "██   "
65        } else {
66            "█    "
67        }
68    }
69
70    /// 品質レベルの文字列を取得
71    pub fn quality_level(&self) -> &'static str {
72        if self.score >= 0.8 {
73            "Excellent"
74        } else if self.score >= 0.6 {
75            "Good"
76        } else if self.score >= 0.4 {
77            "Fair"
78        } else {
79            "Poor"
80        }
81    }
82}
83
84/// Quality Score分析結果
85#[derive(Debug, Clone, Default)]
86pub struct CommitQualityAnalysis {
87    /// コミット一覧(スコア降順)
88    pub commits: Vec<CommitQualityScore>,
89    /// 総コミット数
90    pub total_commits: usize,
91    /// 平均スコア
92    pub avg_score: f64,
93    /// 高品質コミット数(スコア >= 0.6)
94    pub high_quality_count: usize,
95    /// 低品質コミット数(スコア < 0.4)
96    pub low_quality_count: usize,
97}
98
99impl CommitQualityAnalysis {
100    /// コミット数を取得
101    pub fn commit_count(&self) -> usize {
102        self.commits.len()
103    }
104}
105
106/// メッセージ品質を計算 (0.0〜1.0)
107///
108/// 評価基準:
109/// - Conventional Commit形式: 40%
110/// - 長さ(10-72文字): 30%
111/// - 意味のある内容: 30%
112fn calculate_message_quality(message: &str) -> f64 {
113    let mut score = 0.0;
114
115    // Conventional Commit形式チェック(40%)
116    let conventional_prefixes = [
117        "feat:",
118        "fix:",
119        "docs:",
120        "style:",
121        "refactor:",
122        "test:",
123        "chore:",
124        "perf:",
125        "ci:",
126        "build:",
127        "revert:",
128    ];
129    let has_conventional_prefix = conventional_prefixes
130        .iter()
131        .any(|prefix| message.to_lowercase().starts_with(prefix));
132    if has_conventional_prefix {
133        score += 0.4;
134    }
135
136    // 長さチェック(30%)
137    let len = message.len();
138    if (10..=72).contains(&len) {
139        score += 0.3;
140    } else if (5..=100).contains(&len) {
141        // 許容範囲内だが最適ではない
142        score += 0.15;
143    }
144
145    // 意味のある内容チェック(30%)
146    let meaningless_patterns = ["update", "fix", "wip", "changes", "commit", "test", "."];
147    let trimmed = message.trim().to_lowercase();
148    let is_meaningful = !meaningless_patterns.contains(&trimmed.as_str())
149        && message.trim().len() > 3
150        && !message.trim().chars().all(|c| !c.is_alphabetic());
151    if is_meaningful {
152        score += 0.3;
153    }
154
155    score
156}
157
158/// サイズ適切性を計算 (0.0〜1.0)
159///
160/// 評価基準:
161/// - ファイル数(1-5が理想)
162/// - 変更行数(10-200が理想)
163fn calculate_size_appropriateness(
164    files_changed: usize,
165    insertions: usize,
166    deletions: usize,
167) -> f64 {
168    let mut score = 0.0;
169
170    // ファイル数スコア(50%)
171    let file_score = if files_changed == 0 {
172        0.0
173    } else if files_changed <= 5 {
174        0.5
175    } else if files_changed <= 10 {
176        0.3
177    } else if files_changed <= 20 {
178        0.1
179    } else {
180        0.0
181    };
182    score += file_score;
183
184    // 変更行数スコア(50%)
185    let total_changes = insertions + deletions;
186    let change_score = if total_changes == 0 {
187        0.0
188    } else if (10..=200).contains(&total_changes) {
189        0.5
190    } else if (1..=500).contains(&total_changes) {
191        0.3
192    } else if total_changes <= 1000 {
193        0.1
194    } else {
195        0.0
196    };
197    score += change_score;
198
199    score
200}
201
202/// テスト包含を計算 (0.0〜1.0)
203///
204/// 評価基準:
205/// - ソースファイルとテストファイルの両方を含むか
206fn calculate_test_presence(files: &[String]) -> f64 {
207    if files.is_empty() {
208        return 0.0;
209    }
210
211    let has_source = files.iter().any(|f| {
212        let f_lower = f.to_lowercase();
213        !f_lower.contains("test")
214            && !f_lower.contains("spec")
215            && (f_lower.ends_with(".rs")
216                || f_lower.ends_with(".ts")
217                || f_lower.ends_with(".js")
218                || f_lower.ends_with(".py")
219                || f_lower.ends_with(".go")
220                || f_lower.ends_with(".java")
221                || f_lower.ends_with(".rb")
222                || f_lower.ends_with(".c")
223                || f_lower.ends_with(".cpp")
224                || f_lower.ends_with(".h"))
225    });
226
227    let has_test = files.iter().any(|f| {
228        let f_lower = f.to_lowercase();
229        f_lower.contains("test")
230            || f_lower.contains("spec")
231            || f_lower.contains("_test.")
232            || f_lower.contains(".test.")
233    });
234
235    if has_source && has_test {
236        1.0 // 両方あり
237    } else if has_test {
238        0.8 // テストのみ(テストコードの修正など)
239    } else if has_source {
240        0.3 // ソースのみ(テストなし)
241    } else {
242        0.5 // ドキュメントや設定ファイルのみ
243    }
244}
245
246/// 原子性を計算 (0.0〜1.0)
247///
248/// 評価基準:
249/// - 変更ファイルが同ディレクトリに集中しているか
250/// - Change Couplingによる結合度
251fn calculate_atomicity(files: &[String], coupling: &ChangeCouplingAnalysis) -> f64 {
252    if files.is_empty() {
253        return 0.0;
254    }
255
256    if files.len() == 1 {
257        return 1.0; // 単一ファイルは完全に原子的
258    }
259
260    // ディレクトリ集中度(50%)
261    let dirs: std::collections::HashSet<&str> =
262        files.iter().filter_map(|f| f.rsplit('/').nth(1)).collect();
263    let dir_score = if dirs.len() == 1 {
264        0.5 // 全ファイルが同じディレクトリ
265    } else if dirs.len() <= 2 {
266        0.35
267    } else if dirs.len() <= 3 {
268        0.2
269    } else {
270        0.1
271    };
272
273    // 結合度スコア(50%)
274    // ファイル間の結合度が高いほど、一緒に変更されることが多い → 原子的
275    let mut coupling_sum = 0.0;
276    let mut coupling_count = 0;
277
278    for file in files {
279        for other_file in files {
280            if file != other_file {
281                // coupling.couplingsからfileとother_fileのペアを探す
282                if let Some(c) = coupling
283                    .couplings
284                    .iter()
285                    .find(|c| &c.file == file && &c.coupled_file == other_file)
286                {
287                    coupling_sum += c.coupling_percent;
288                    coupling_count += 1;
289                }
290            }
291        }
292    }
293
294    let coupling_score = if coupling_count > 0 {
295        (coupling_sum / coupling_count as f64) * 0.5
296    } else {
297        0.25 // 結合情報がない場合はデフォルト値
298    };
299
300    dir_score + coupling_score
301}
302
303/// Quality Scoreを計算
304///
305/// 計算式:
306/// - メッセージ品質: 30%
307/// - サイズ適切性: 25%
308/// - テスト包含: 25%
309/// - 原子性: 20%
310pub fn calculate_quality_scores(
311    events: &[&GitEvent],
312    get_files: impl Fn(&str) -> Option<Vec<String>>,
313    coupling: &ChangeCouplingAnalysis,
314) -> CommitQualityAnalysis {
315    let mut commits = Vec::new();
316    let mut total_score = 0.0;
317    let mut high_quality_count = 0;
318    let mut low_quality_count = 0;
319
320    for event in events {
321        let files = get_files(&event.short_hash).unwrap_or_default();
322
323        // 各スコア要素を計算
324        let message_score = calculate_message_quality(&event.message) * 0.30;
325        let size_score =
326            calculate_size_appropriateness(files.len(), event.files_added, event.files_deleted)
327                * 0.25;
328        let test_score = calculate_test_presence(&files) * 0.25;
329        let atomicity_score = calculate_atomicity(&files, coupling) * 0.20;
330
331        // 総合スコア
332        let score = message_score + size_score + test_score + atomicity_score;
333
334        commits.push(CommitQualityScore {
335            commit_hash: event.short_hash.clone(),
336            commit_message: event.message.clone(),
337            author: event.author.clone(),
338            date: event.timestamp,
339            files_changed: files.len(),
340            insertions: event.files_added,
341            deletions: event.files_deleted,
342            score,
343            message_score,
344            size_score,
345            test_score,
346            atomicity_score,
347        });
348
349        total_score += score;
350        if score >= 0.6 {
351            high_quality_count += 1;
352        }
353        if score < 0.4 {
354            low_quality_count += 1;
355        }
356    }
357
358    // スコア降順でソート
359    commits.sort_by(|a, b| {
360        b.score
361            .partial_cmp(&a.score)
362            .unwrap_or(std::cmp::Ordering::Equal)
363    });
364
365    let total_commits = commits.len();
366    let avg_score = if total_commits > 0 {
367        total_score / total_commits as f64
368    } else {
369        0.0
370    };
371
372    CommitQualityAnalysis {
373        commits,
374        total_commits,
375        avg_score,
376        high_quality_count,
377        low_quality_count,
378    }
379}
380
381// =============================================================================
382// Impact Score
383// =============================================================================
384
385/// コミットのImpact Score(影響度スコア)
386#[derive(Debug, Clone)]
387pub struct CommitImpactScore {
388    /// コミットハッシュ
389    pub commit_hash: String,
390    /// コミットメッセージ
391    pub commit_message: String,
392    /// 著者
393    pub author: String,
394    /// 日付
395    pub date: DateTime<Local>,
396    /// 変更ファイル数
397    pub files_changed: usize,
398    /// 追加行数
399    pub insertions: usize,
400    /// 削除行数
401    pub deletions: usize,
402    /// 総合スコア (0.0〜1.0)
403    pub score: f64,
404    /// ファイル数要素 (0.0〜0.4)
405    pub file_score: f64,
406    /// 変更行数要素 (0.0〜0.4)
407    pub change_score: f64,
408    /// ファイル重要度要素 (0.0〜0.2)
409    pub heat_score: f64,
410}
411
412impl CommitImpactScore {
413    /// スコアに応じた色を取得
414    pub fn score_color(&self) -> &'static str {
415        if self.score >= 0.7 {
416            "red" // High Impact
417        } else if self.score >= 0.4 {
418            "yellow" // Medium Impact
419        } else {
420            "green" // Low Impact
421        }
422    }
423
424    /// スコアバーを生成(5段階)
425    pub fn score_bar(&self) -> &'static str {
426        if self.score >= 0.8 {
427            "█████"
428        } else if self.score >= 0.6 {
429            "████ "
430        } else if self.score >= 0.4 {
431            "███  "
432        } else if self.score >= 0.2 {
433            "██   "
434        } else {
435            "█    "
436        }
437    }
438}
439
440/// Impact Score分析結果
441#[derive(Debug, Clone, Default)]
442pub struct CommitImpactAnalysis {
443    /// コミット一覧(スコア降順)
444    pub commits: Vec<CommitImpactScore>,
445    /// 総コミット数
446    pub total_commits: usize,
447    /// 平均スコア
448    pub avg_score: f64,
449    /// 最大スコア
450    pub max_score: f64,
451    /// High Impact(スコア >= 0.7)のコミット数
452    pub high_impact_count: usize,
453}
454
455impl CommitImpactAnalysis {
456    /// コミット数を取得
457    pub fn commit_count(&self) -> usize {
458        self.commits.len()
459    }
460}
461
462/// Impact Scoreを計算
463///
464/// 計算式:
465/// - ファイル数要素: (files_changed / 50).min(1.0) * 0.4
466/// - 変更行数要素: (total_changes / 500).min(1.0) * 0.4
467/// - ファイル重要度要素: avg_file_heat * 0.2
468pub fn calculate_impact_scores(
469    events: &[&GitEvent],
470    get_files: impl Fn(&str) -> Option<Vec<String>>,
471    file_heatmap: &FileHeatmap,
472) -> CommitImpactAnalysis {
473    let mut commits = Vec::new();
474    let mut total_score = 0.0;
475    let mut max_score = 0.0f64;
476    let mut high_impact_count = 0;
477
478    // ファイルパスからヒート値を取得するためのマップを作成
479    let heat_map: HashMap<&str, f64> = file_heatmap
480        .files
481        .iter()
482        .map(|f| (f.path.as_str(), f.heat_level()))
483        .collect();
484
485    for event in events {
486        let files = get_files(&event.short_hash).unwrap_or_default();
487        let files_changed = files.len();
488        let total_changes = event.files_added + event.files_deleted;
489
490        // ファイル数要素 (40%)
491        let file_score = (files_changed as f64 / 50.0).min(1.0) * 0.4;
492
493        // 変更行数要素 (40%)
494        let change_score = (total_changes as f64 / 500.0).min(1.0) * 0.4;
495
496        // ファイル重要度要素 (20%)
497        let avg_file_heat = if files.is_empty() {
498            0.0
499        } else {
500            let total_heat: f64 = files
501                .iter()
502                .map(|f| heat_map.get(f.as_str()).copied().unwrap_or(0.0))
503                .sum();
504            total_heat / files.len() as f64
505        };
506        let heat_score = avg_file_heat * 0.2;
507
508        // 総合スコア
509        let score = file_score + change_score + heat_score;
510
511        commits.push(CommitImpactScore {
512            commit_hash: event.short_hash.clone(),
513            commit_message: event.message.clone(),
514            author: event.author.clone(),
515            date: event.timestamp,
516            files_changed,
517            insertions: event.files_added,
518            deletions: event.files_deleted,
519            score,
520            file_score,
521            change_score,
522            heat_score,
523        });
524
525        total_score += score;
526        max_score = max_score.max(score);
527        if score >= 0.7 {
528            high_impact_count += 1;
529        }
530    }
531
532    // スコア降順でソート
533    commits.sort_by(|a, b| {
534        b.score
535            .partial_cmp(&a.score)
536            .unwrap_or(std::cmp::Ordering::Equal)
537    });
538
539    let total_commits = commits.len();
540    let avg_score = if total_commits > 0 {
541        total_score / total_commits as f64
542    } else {
543        0.0
544    };
545
546    CommitImpactAnalysis {
547        commits,
548        total_commits,
549        avg_score,
550        max_score,
551        high_impact_count,
552    }
553}
554
555// =============================================================================
556// Change Coupling
557// =============================================================================
558
559/// ファイル間の共変更関係(Change Coupling)
560#[derive(Debug, Clone)]
561pub struct FileCoupling {
562    /// 対象ファイル
563    pub file: String,
564    /// 結合先ファイル
565    pub coupled_file: String,
566    /// 共変更回数
567    pub co_change_count: usize,
568    /// 対象ファイルの総変更回数
569    pub file_change_count: usize,
570    /// 結合度(0.0-1.0)
571    pub coupling_percent: f64,
572}
573
574impl FileCoupling {
575    /// 結合度バーを生成(10段階)
576    pub fn coupling_bar(&self) -> String {
577        let filled = (self.coupling_percent * 10.0).round() as usize;
578        let empty = 10 - filled;
579        format!("[{}{}]", "█".repeat(filled), "░".repeat(empty))
580    }
581}
582
583/// Change Coupling分析結果
584#[derive(Debug, Clone, Default)]
585pub struct ChangeCouplingAnalysis {
586    /// ファイル間の結合関係(結合度順)
587    pub couplings: Vec<FileCoupling>,
588    /// 高結合(70%以上)の数
589    pub high_coupling_count: usize,
590    /// 分析されたファイル数
591    pub total_files_analyzed: usize,
592}
593
594impl ChangeCouplingAnalysis {
595    /// 結合数を取得
596    pub fn coupling_count(&self) -> usize {
597        self.couplings.len()
598    }
599
600    /// ファイル別にグループ化された結合を取得
601    pub fn grouped_by_file(&self) -> Vec<(&str, Vec<&FileCoupling>)> {
602        use std::collections::HashMap;
603        let mut groups: HashMap<&str, Vec<&FileCoupling>> = HashMap::new();
604        for coupling in &self.couplings {
605            groups.entry(&coupling.file).or_default().push(coupling);
606        }
607        let mut result: Vec<_> = groups.into_iter().collect();
608        // ファイルの総変更回数でソート(降順)
609        result.sort_by(|a, b| {
610            let count_a = a.1.first().map(|c| c.file_change_count).unwrap_or(0);
611            let count_b = b.1.first().map(|c| c.file_change_count).unwrap_or(0);
612            count_b.cmp(&count_a)
613        });
614        result
615    }
616}
617
618/// Change Coupling(共変更関係)を計算
619///
620/// # Arguments
621/// * `events` - コミットイベント一覧
622/// * `get_files` - コミットハッシュからファイル一覧を取得するクロージャ
623/// * `min_commits` - 最低コミット数(これ未満のファイルは除外)
624/// * `min_coupling` - 最低結合度(これ未満の結合は除外)
625///
626/// # Returns
627/// Change Coupling分析結果
628pub fn calculate_change_coupling(
629    events: &[&GitEvent],
630    get_files: impl Fn(&str) -> Option<Vec<String>>,
631    min_commits: usize,
632    min_coupling: f64,
633) -> ChangeCouplingAnalysis {
634    use std::collections::{HashMap, HashSet};
635
636    // 1. 各ファイルの変更回数をカウント
637    let mut file_change_counts: HashMap<String, usize> = HashMap::new();
638
639    // 2. 各コミットのファイル一覧を収集
640    let mut commit_files: Vec<HashSet<String>> = Vec::new();
641
642    for event in events {
643        if let Some(files) = get_files(&event.short_hash) {
644            let file_set: HashSet<String> = files.iter().cloned().collect();
645            for file in &file_set {
646                *file_change_counts.entry(file.clone()).or_insert(0) += 1;
647            }
648            commit_files.push(file_set);
649        }
650    }
651
652    // 3. ファイルペアごとの共変更回数をカウント
653    let mut pair_counts: HashMap<(String, String), usize> = HashMap::new();
654
655    for files in &commit_files {
656        let files_vec: Vec<_> = files.iter().collect();
657        for i in 0..files_vec.len() {
658            for j in (i + 1)..files_vec.len() {
659                let file_a = files_vec[i].clone();
660                let file_b = files_vec[j].clone();
661                // 両方向で記録(A→BとB→A)
662                *pair_counts
663                    .entry((file_a.clone(), file_b.clone()))
664                    .or_insert(0) += 1;
665                *pair_counts.entry((file_b, file_a)).or_insert(0) += 1;
666            }
667        }
668    }
669
670    // 4. 結合度を計算してフィルタリング
671    let mut couplings: Vec<FileCoupling> = Vec::new();
672    let mut high_coupling_count = 0;
673    let mut analyzed_files: HashSet<String> = HashSet::new();
674
675    for ((file, coupled_file), co_change_count) in &pair_counts {
676        let file_change_count = *file_change_counts.get(file).unwrap_or(&0);
677
678        // min_commits以上の変更があるファイルのみ対象
679        if file_change_count < min_commits {
680            continue;
681        }
682
683        let coupling_percent = *co_change_count as f64 / file_change_count as f64;
684
685        // min_coupling以上の結合度のみ対象
686        if coupling_percent < min_coupling {
687            continue;
688        }
689
690        analyzed_files.insert(file.clone());
691
692        if coupling_percent >= 0.7 {
693            high_coupling_count += 1;
694        }
695
696        couplings.push(FileCoupling {
697            file: file.clone(),
698            coupled_file: coupled_file.clone(),
699            co_change_count: *co_change_count,
700            file_change_count,
701            coupling_percent,
702        });
703    }
704
705    // 5. 結合度の高い順にソート
706    couplings.sort_by(|a, b| {
707        b.coupling_percent
708            .partial_cmp(&a.coupling_percent)
709            .unwrap_or(std::cmp::Ordering::Equal)
710    });
711
712    // 高結合は重複カウントを避けるため2で割る(A→BとB→Aの両方がカウントされているため)
713    high_coupling_count /= 2;
714
715    ChangeCouplingAnalysis {
716        couplings,
717        high_coupling_count,
718        total_files_analyzed: analyzed_files.len(),
719    }
720}
721
722/// 著者別統計情報
723#[derive(Debug, Clone)]
724pub struct AuthorStats {
725    /// 著者名
726    pub name: String,
727    /// コミット数
728    pub commit_count: usize,
729    /// 追加行数
730    pub insertions: usize,
731    /// 削除行数
732    pub deletions: usize,
733    /// 最終コミット日時
734    pub last_commit: DateTime<Local>,
735}
736
737impl AuthorStats {
738    /// コミット割合を計算(パーセント)
739    pub fn commit_percentage(&self, total: usize) -> f64 {
740        if total == 0 {
741            0.0
742        } else {
743            (self.commit_count as f64 / total as f64) * 100.0
744        }
745    }
746}
747
748/// リポジトリ全体の統計情報
749#[derive(Debug, Clone, Default)]
750pub struct RepoStats {
751    /// 著者別統計(コミット数降順)
752    pub authors: Vec<AuthorStats>,
753    /// 総コミット数
754    pub total_commits: usize,
755    /// 総追加行数
756    pub total_insertions: usize,
757    /// 総削除行数
758    pub total_deletions: usize,
759}
760
761impl RepoStats {
762    /// 著者数を取得
763    pub fn author_count(&self) -> usize {
764        self.authors.len()
765    }
766}
767
768/// ファイル変更頻度のエントリ
769#[derive(Debug, Clone)]
770pub struct FileHeatmapEntry {
771    /// ファイルパス
772    pub path: String,
773    /// 変更回数
774    pub change_count: usize,
775    /// 最大変更回数(正規化用)
776    pub max_changes: usize,
777}
778
779impl FileHeatmapEntry {
780    /// ヒートレベルを計算(0.0〜1.0)
781    pub fn heat_level(&self) -> f64 {
782        if self.max_changes == 0 {
783            0.0
784        } else {
785            self.change_count as f64 / self.max_changes as f64
786        }
787    }
788
789    /// ヒートバーを生成(5段階)
790    pub fn heat_bar(&self) -> &'static str {
791        let level = self.heat_level();
792        if level >= 0.8 {
793            "█████"
794        } else if level >= 0.6 {
795            "████ "
796        } else if level >= 0.4 {
797            "███  "
798        } else if level >= 0.2 {
799            "██   "
800        } else {
801            "█    "
802        }
803    }
804}
805
806/// 集約レベル(ヒートマップ表示用)
807#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
808pub enum AggregationLevel {
809    /// ファイル単位(デフォルト)
810    #[default]
811    Files,
812    /// 浅い集約(2階層目まで、例: src/auth/)
813    Shallow,
814    /// 深い集約(1階層のみ、例: src/)
815    Deep,
816}
817
818impl AggregationLevel {
819    /// 次の集約レベルへ切り替え
820    pub fn next(&self) -> Self {
821        match self {
822            AggregationLevel::Files => AggregationLevel::Shallow,
823            AggregationLevel::Shallow => AggregationLevel::Deep,
824            AggregationLevel::Deep => AggregationLevel::Files,
825        }
826    }
827
828    /// 前の集約レベルへ切り替え
829    pub fn prev(&self) -> Self {
830        match self {
831            AggregationLevel::Files => AggregationLevel::Deep,
832            AggregationLevel::Shallow => AggregationLevel::Files,
833            AggregationLevel::Deep => AggregationLevel::Shallow,
834        }
835    }
836
837    /// 表示名を取得
838    pub fn display_name(&self) -> &'static str {
839        match self {
840            AggregationLevel::Files => "Files",
841            AggregationLevel::Shallow => "Directories (2 levels)",
842            AggregationLevel::Deep => "Directories (top level)",
843        }
844    }
845}
846
847/// ファイル変更ヒートマップ
848#[derive(Debug, Clone, Default)]
849pub struct FileHeatmap {
850    /// ファイル一覧(変更回数降順)
851    pub files: Vec<FileHeatmapEntry>,
852    /// 総ファイル数
853    pub total_files: usize,
854    /// 現在の集約レベル
855    pub aggregation_level: AggregationLevel,
856}
857
858impl FileHeatmap {
859    /// ファイル数を取得
860    pub fn file_count(&self) -> usize {
861        self.files.len()
862    }
863
864    /// 集約レベルを変更した新しいヒートマップを作成
865    pub fn with_aggregation(&self, level: AggregationLevel) -> FileHeatmap {
866        if level == AggregationLevel::Files {
867            // ファイルレベルはそのまま返す
868            return FileHeatmap {
869                files: self.files.clone(),
870                total_files: self.total_files,
871                aggregation_level: level,
872            };
873        }
874
875        // ディレクトリ単位で集約
876        let mut dir_counts: HashMap<String, usize> = HashMap::new();
877
878        for entry in &self.files {
879            let dir = extract_directory(&entry.path, level);
880            *dir_counts.entry(dir).or_insert(0) += entry.change_count;
881        }
882
883        let max_changes = dir_counts.values().copied().max().unwrap_or(0);
884
885        let mut files: Vec<FileHeatmapEntry> = dir_counts
886            .into_iter()
887            .map(|(path, change_count)| FileHeatmapEntry {
888                path,
889                change_count,
890                max_changes,
891            })
892            .collect();
893
894        // 変更回数降順でソート
895        files.sort_by(|a, b| b.change_count.cmp(&a.change_count));
896
897        FileHeatmap {
898            total_files: self.total_files,
899            files,
900            aggregation_level: level,
901        }
902    }
903}
904
905/// ディレクトリパスを抽出
906fn extract_directory(path: &str, level: AggregationLevel) -> String {
907    let parts: Vec<&str> = path.split('/').collect();
908    match level {
909        AggregationLevel::Files => path.to_string(),
910        AggregationLevel::Shallow => {
911            // src/auth/login.rs → src/auth/
912            if parts.len() > 2 {
913                format!("{}/{}/", parts[0], parts[1])
914            } else if parts.len() == 2 {
915                format!("{}/", parts[0])
916            } else {
917                path.to_string()
918            }
919        }
920        AggregationLevel::Deep => {
921            // src/auth/login.rs → src/
922            if parts.len() > 1 {
923                format!("{}/", parts[0])
924            } else {
925                path.to_string()
926            }
927        }
928    }
929}
930
931/// イベント一覧からファイル変更ヒートマップを計算
932pub fn calculate_file_heatmap(
933    events: &[&GitEvent],
934    get_files: impl Fn(&str) -> Option<Vec<String>>,
935) -> FileHeatmap {
936    let mut file_counts: HashMap<String, usize> = HashMap::new();
937
938    for event in events {
939        if let Some(files) = get_files(&event.short_hash) {
940            for file in files {
941                *file_counts.entry(file).or_insert(0) += 1;
942            }
943        }
944    }
945
946    let max_changes = file_counts.values().copied().max().unwrap_or(0);
947
948    let mut files: Vec<FileHeatmapEntry> = file_counts
949        .into_iter()
950        .map(|(path, change_count)| FileHeatmapEntry {
951            path,
952            change_count,
953            max_changes,
954        })
955        .collect();
956
957    // 変更回数降順でソート
958    files.sort_by(|a, b| b.change_count.cmp(&a.change_count));
959
960    let total_files = files.len();
961    FileHeatmap {
962        files,
963        total_files,
964        aggregation_level: AggregationLevel::Files,
965    }
966}
967
968/// 活動タイムライン(曜日×時間帯のヒートマップ)
969#[derive(Debug, Clone, Default)]
970pub struct ActivityTimeline {
971    /// 7x24のグリッド(曜日 x 時間帯)
972    /// grid[曜日][時間] = コミット数
973    /// 曜日: 0=月, 1=火, 2=水, 3=木, 4=金, 5=土, 6=日
974    pub grid: [[usize; 24]; 7],
975    /// 総コミット数
976    pub total_commits: usize,
977    /// ピーク曜日(0-6)
978    pub peak_day: usize,
979    /// ピーク時間(0-23)
980    pub peak_hour: usize,
981    /// ピーク時のコミット数
982    pub peak_count: usize,
983    /// 最大コミット数(正規化用)
984    pub max_count: usize,
985}
986
987impl ActivityTimeline {
988    /// 曜日名を取得
989    pub fn day_name(day: usize) -> &'static str {
990        match day {
991            0 => "Mon",
992            1 => "Tue",
993            2 => "Wed",
994            3 => "Thu",
995            4 => "Fri",
996            5 => "Sat",
997            6 => "Sun",
998            _ => "???",
999        }
1000    }
1001
1002    /// ヒートレベルを計算(0.0〜1.0)
1003    pub fn heat_level(&self, day: usize, hour: usize) -> f64 {
1004        if self.max_count == 0 {
1005            0.0
1006        } else {
1007            self.grid[day][hour] as f64 / self.max_count as f64
1008        }
1009    }
1010
1011    /// ヒートマップの文字を取得
1012    pub fn heat_char(level: f64) -> &'static str {
1013        if level >= 0.8 {
1014            "██"
1015        } else if level >= 0.6 {
1016            "▓▓"
1017        } else if level >= 0.4 {
1018            "▒▒"
1019        } else if level >= 0.2 {
1020            "░░"
1021        } else if level > 0.0 {
1022            "··"
1023        } else {
1024            "  "
1025        }
1026    }
1027
1028    /// ピーク時間帯の文字列を取得
1029    pub fn peak_summary(&self) -> String {
1030        if self.peak_count == 0 {
1031            "No activity".to_string()
1032        } else {
1033            format!(
1034                "{} {:02}:00-{:02}:00 ({} commits)",
1035                Self::day_name(self.peak_day),
1036                self.peak_hour,
1037                (self.peak_hour + 1) % 24,
1038                self.peak_count
1039            )
1040        }
1041    }
1042}
1043
1044/// イベント一覧から活動タイムラインを計算
1045pub fn calculate_activity_timeline(events: &[&GitEvent]) -> ActivityTimeline {
1046    use chrono::Datelike;
1047    use chrono::Timelike;
1048
1049    let mut timeline = ActivityTimeline {
1050        total_commits: events.len(),
1051        ..Default::default()
1052    };
1053
1054    for event in events {
1055        // chrono::Weekdayは月曜=0...日曜=6
1056        let day = event.timestamp.weekday().num_days_from_monday() as usize;
1057        let hour = event.timestamp.hour() as usize;
1058
1059        timeline.grid[day][hour] += 1;
1060    }
1061
1062    // ピークを計算
1063    let mut max_count = 0usize;
1064    for (day, hours) in timeline.grid.iter().enumerate() {
1065        for (hour, &count) in hours.iter().enumerate() {
1066            if count > max_count {
1067                max_count = count;
1068                timeline.peak_day = day;
1069                timeline.peak_hour = hour;
1070                timeline.peak_count = count;
1071            }
1072        }
1073    }
1074    timeline.max_count = max_count;
1075
1076    timeline
1077}
1078
1079/// コードオーナーシップのエントリ
1080#[derive(Debug, Clone)]
1081pub struct CodeOwnershipEntry {
1082    /// パス(ディレクトリまたはファイル)
1083    pub path: String,
1084    /// 主要著者
1085    pub primary_author: String,
1086    /// 主要著者のコミット数
1087    pub primary_commits: usize,
1088    /// 総コミット数
1089    pub total_commits: usize,
1090    /// 深さ(インデント用)
1091    pub depth: usize,
1092    /// ディレクトリかどうか
1093    pub is_directory: bool,
1094}
1095
1096impl CodeOwnershipEntry {
1097    /// 主要著者の割合を計算(パーセント)
1098    pub fn ownership_percentage(&self) -> f64 {
1099        if self.total_commits == 0 {
1100            0.0
1101        } else {
1102            (self.primary_commits as f64 / self.total_commits as f64) * 100.0
1103        }
1104    }
1105}
1106
1107/// コードオーナーシップ分析結果
1108#[derive(Debug, Clone, Default)]
1109pub struct CodeOwnership {
1110    /// エントリ一覧(階層順)
1111    pub entries: Vec<CodeOwnershipEntry>,
1112    /// 総ファイル数
1113    pub total_files: usize,
1114}
1115
1116impl CodeOwnership {
1117    /// エントリ数を取得
1118    pub fn entry_count(&self) -> usize {
1119        self.entries.len()
1120    }
1121}
1122
1123/// イベント一覧からコードオーナーシップを計算
1124pub fn calculate_ownership(
1125    events: &[&GitEvent],
1126    get_files: impl Fn(&str) -> Option<Vec<String>>,
1127) -> CodeOwnership {
1128    // ファイルごとの著者別コミット数を集計
1129    let mut file_author_counts: HashMap<String, HashMap<String, usize>> = HashMap::new();
1130
1131    for event in events {
1132        if let Some(files) = get_files(&event.short_hash) {
1133            for file in files {
1134                let author_counts = file_author_counts.entry(file).or_default();
1135                *author_counts.entry(event.author.clone()).or_insert(0) += 1;
1136            }
1137        }
1138    }
1139
1140    // ディレクトリごとの著者別コミット数を集計
1141    let mut dir_author_counts: HashMap<String, HashMap<String, usize>> = HashMap::new();
1142
1143    for (file_path, author_counts) in &file_author_counts {
1144        // ファイルパスからディレクトリパスを抽出
1145        let parts: Vec<&str> = file_path.split('/').collect();
1146        for i in 1..parts.len() {
1147            let dir_path = parts[..i].join("/");
1148            let dir_counts = dir_author_counts.entry(dir_path).or_default();
1149            for (author, count) in author_counts {
1150                *dir_counts.entry(author.clone()).or_insert(0) += count;
1151            }
1152        }
1153    }
1154
1155    // エントリを生成
1156    let mut entries = Vec::new();
1157
1158    // ディレクトリを階層順にソート
1159    let mut dir_paths: Vec<String> = dir_author_counts.keys().cloned().collect();
1160    dir_paths.sort();
1161
1162    for dir_path in dir_paths {
1163        let author_counts = &dir_author_counts[&dir_path];
1164        let (primary_author, primary_commits) = author_counts
1165            .iter()
1166            .max_by_key(|(_, c)| *c)
1167            .map(|(a, c)| (a.clone(), *c))
1168            .unwrap_or_default();
1169        let total_commits: usize = author_counts.values().sum();
1170        let depth = dir_path.matches('/').count();
1171
1172        entries.push(CodeOwnershipEntry {
1173            path: dir_path,
1174            primary_author,
1175            primary_commits,
1176            total_commits,
1177            depth,
1178            is_directory: true,
1179        });
1180    }
1181
1182    // ファイルも追加(ディレクトリの後に)
1183    let mut file_paths: Vec<String> = file_author_counts.keys().cloned().collect();
1184    file_paths.sort();
1185
1186    for file_path in file_paths {
1187        let author_counts = &file_author_counts[&file_path];
1188        let (primary_author, primary_commits) = author_counts
1189            .iter()
1190            .max_by_key(|(_, c)| *c)
1191            .map(|(a, c)| (a.clone(), *c))
1192            .unwrap_or_default();
1193        let total_commits: usize = author_counts.values().sum();
1194        let depth = file_path.matches('/').count();
1195
1196        entries.push(CodeOwnershipEntry {
1197            path: file_path,
1198            primary_author,
1199            primary_commits,
1200            total_commits,
1201            depth,
1202            is_directory: false,
1203        });
1204    }
1205
1206    // ディレクトリとファイルを交互に表示するため、パスでソート
1207    entries.sort_by(|a, b| a.path.cmp(&b.path));
1208
1209    let total_files = file_author_counts.len();
1210    CodeOwnership {
1211        entries,
1212        total_files,
1213    }
1214}
1215
1216/// イベント一覧から統計情報を計算
1217pub fn calculate_stats(events: &[&GitEvent]) -> RepoStats {
1218    let mut author_map: HashMap<String, AuthorStats> = HashMap::new();
1219    let mut total_insertions = 0usize;
1220    let mut total_deletions = 0usize;
1221
1222    for event in events {
1223        total_insertions += event.files_added;
1224        total_deletions += event.files_deleted;
1225
1226        let entry = author_map
1227            .entry(event.author.clone())
1228            .or_insert(AuthorStats {
1229                name: event.author.clone(),
1230                commit_count: 0,
1231                insertions: 0,
1232                deletions: 0,
1233                last_commit: event.timestamp,
1234            });
1235
1236        entry.commit_count += 1;
1237        entry.insertions += event.files_added;
1238        entry.deletions += event.files_deleted;
1239
1240        // 最新のコミット日時を更新
1241        if event.timestamp > entry.last_commit {
1242            entry.last_commit = event.timestamp;
1243        }
1244    }
1245
1246    // コミット数降順でソート
1247    let mut authors: Vec<AuthorStats> = author_map.into_values().collect();
1248    authors.sort_by(|a, b| b.commit_count.cmp(&a.commit_count));
1249
1250    RepoStats {
1251        authors,
1252        total_commits: events.len(),
1253        total_insertions,
1254        total_deletions,
1255    }
1256}
1257
1258// =============================================================================
1259// Bus Factor Analysis
1260// =============================================================================
1261
1262/// バスファクターエントリ
1263#[derive(Debug, Clone)]
1264pub struct BusFactorEntry {
1265    /// パス(ディレクトリまたはファイル)
1266    pub path: String,
1267    /// バスファクター値(知識を持つ人数)
1268    pub bus_factor: usize,
1269    /// 主要な貢献者リスト(コミット数順)
1270    pub contributors: Vec<ContributorInfo>,
1271    /// 総コミット数
1272    pub total_commits: usize,
1273    /// リスクレベル
1274    pub risk_level: BusFactorRisk,
1275    /// ディレクトリかどうか
1276    pub is_directory: bool,
1277}
1278
1279/// 貢献者情報
1280#[derive(Debug, Clone)]
1281pub struct ContributorInfo {
1282    /// 著者名
1283    pub name: String,
1284    /// コミット数
1285    pub commit_count: usize,
1286    /// 貢献割合(パーセント)
1287    pub contribution_percent: f64,
1288}
1289
1290/// バスファクターリスクレベル
1291#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1292pub enum BusFactorRisk {
1293    /// 高リスク(1人のみ)
1294    High,
1295    /// 中リスク(2人)
1296    Medium,
1297    /// 低リスク(3人以上)
1298    Low,
1299}
1300
1301impl BusFactorRisk {
1302    /// リスクレベルの表示名
1303    pub fn display_name(&self) -> &'static str {
1304        match self {
1305            BusFactorRisk::High => "High Risk",
1306            BusFactorRisk::Medium => "Medium Risk",
1307            BusFactorRisk::Low => "Low Risk",
1308        }
1309    }
1310
1311    /// リスクレベルの色
1312    pub fn color(&self) -> &'static str {
1313        match self {
1314            BusFactorRisk::High => "red",
1315            BusFactorRisk::Medium => "yellow",
1316            BusFactorRisk::Low => "green",
1317        }
1318    }
1319}
1320
1321/// バスファクター分析結果
1322#[derive(Debug, Clone, Default)]
1323pub struct BusFactorAnalysis {
1324    /// エントリ一覧(リスク順)
1325    pub entries: Vec<BusFactorEntry>,
1326    /// 高リスク領域数
1327    pub high_risk_count: usize,
1328    /// 中リスク領域数
1329    pub medium_risk_count: usize,
1330    /// 分析されたパス数
1331    pub total_paths_analyzed: usize,
1332}
1333
1334impl BusFactorAnalysis {
1335    /// エントリ数を取得
1336    pub fn entry_count(&self) -> usize {
1337        self.entries.len()
1338    }
1339}
1340
1341/// バスファクターを計算
1342///
1343/// バスファクター = そのコード領域に関する知識を持つ人数
1344/// 「その人がいなくなったらプロジェクトが停止するリスク」を測定
1345///
1346/// 計算方法:
1347/// - 各パスについて、50%以上の貢献をしている人数をカウント
1348/// - 1人だけが50%以上なら bus_factor = 1(高リスク)
1349pub fn calculate_bus_factor(
1350    events: &[&GitEvent],
1351    get_files: impl Fn(&str) -> Option<Vec<String>>,
1352    min_commits: usize,
1353) -> BusFactorAnalysis {
1354    // ディレクトリごとの著者別コミット数を集計
1355    let mut dir_author_counts: HashMap<String, HashMap<String, usize>> = HashMap::new();
1356
1357    for event in events {
1358        if let Some(files) = get_files(&event.short_hash) {
1359            for file in &files {
1360                // トップレベルディレクトリを抽出
1361                let parts: Vec<&str> = file.split('/').collect();
1362                if parts.len() > 1 {
1363                    let top_dir = parts[0].to_string();
1364                    let counts = dir_author_counts.entry(top_dir).or_default();
1365                    *counts.entry(event.author.clone()).or_insert(0) += 1;
1366                }
1367                // 2階層目のディレクトリも集計
1368                if parts.len() > 2 {
1369                    let two_level_dir = format!("{}/{}", parts[0], parts[1]);
1370                    let counts = dir_author_counts.entry(two_level_dir).or_default();
1371                    *counts.entry(event.author.clone()).or_insert(0) += 1;
1372                }
1373            }
1374        }
1375    }
1376
1377    let mut entries = Vec::new();
1378    let mut high_risk_count = 0;
1379    let mut medium_risk_count = 0;
1380
1381    for (path, author_counts) in &dir_author_counts {
1382        let total_commits: usize = author_counts.values().sum();
1383
1384        // 最小コミット数に満たない場合はスキップ
1385        if total_commits < min_commits {
1386            continue;
1387        }
1388
1389        // 貢献者情報を作成(コミット数順)
1390        let mut contributors: Vec<ContributorInfo> = author_counts
1391            .iter()
1392            .map(|(name, &count)| ContributorInfo {
1393                name: name.clone(),
1394                commit_count: count,
1395                contribution_percent: (count as f64 / total_commits as f64) * 100.0,
1396            })
1397            .collect();
1398        contributors.sort_by(|a, b| b.commit_count.cmp(&a.commit_count));
1399
1400        // バスファクター計算: 累積貢献が50%を超えるまでに必要な人数
1401        let mut cumulative = 0.0;
1402        let mut bus_factor = 0;
1403        for contributor in &contributors {
1404            cumulative += contributor.contribution_percent;
1405            bus_factor += 1;
1406            if cumulative >= 50.0 {
1407                break;
1408            }
1409        }
1410
1411        let risk_level = match bus_factor {
1412            1 => {
1413                high_risk_count += 1;
1414                BusFactorRisk::High
1415            }
1416            2 => {
1417                medium_risk_count += 1;
1418                BusFactorRisk::Medium
1419            }
1420            _ => BusFactorRisk::Low,
1421        };
1422
1423        entries.push(BusFactorEntry {
1424            path: path.clone(),
1425            bus_factor,
1426            contributors: contributors.into_iter().take(5).collect(), // 上位5人のみ
1427            total_commits,
1428            risk_level,
1429            is_directory: true,
1430        });
1431    }
1432
1433    // リスク順(高リスク→低リスク)、同じリスクならコミット数順でソート
1434    entries.sort_by(|a, b| match (&a.risk_level, &b.risk_level) {
1435        (BusFactorRisk::High, BusFactorRisk::High)
1436        | (BusFactorRisk::Medium, BusFactorRisk::Medium)
1437        | (BusFactorRisk::Low, BusFactorRisk::Low) => b.total_commits.cmp(&a.total_commits),
1438        (BusFactorRisk::High, _) => std::cmp::Ordering::Less,
1439        (_, BusFactorRisk::High) => std::cmp::Ordering::Greater,
1440        (BusFactorRisk::Medium, BusFactorRisk::Low) => std::cmp::Ordering::Less,
1441        (BusFactorRisk::Low, BusFactorRisk::Medium) => std::cmp::Ordering::Greater,
1442    });
1443
1444    BusFactorAnalysis {
1445        total_paths_analyzed: entries.len(),
1446        entries,
1447        high_risk_count,
1448        medium_risk_count,
1449    }
1450}
1451
1452// =============================================================================
1453// Technical Debt Score
1454// =============================================================================
1455
1456/// 技術的負債エントリ
1457#[derive(Debug, Clone)]
1458pub struct TechDebtEntry {
1459    /// ファイルパス
1460    pub path: String,
1461    /// 技術的負債スコア (0.0〜1.0)
1462    pub score: f64,
1463    /// 変更頻度スコア (0.0〜1.0)
1464    pub churn_score: f64,
1465    /// 複雑度スコア (0.0〜1.0) - ファイルサイズベース
1466    pub complexity_score: f64,
1467    /// 年齢スコア (0.0〜1.0) - 最終変更からの経過
1468    pub age_score: f64,
1469    /// 変更回数
1470    pub change_count: usize,
1471    /// 総変更行数
1472    pub total_changes: usize,
1473    /// 負債レベル
1474    pub debt_level: TechDebtLevel,
1475}
1476
1477/// 技術的負債レベル
1478#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1479pub enum TechDebtLevel {
1480    /// 高負債(要対応)
1481    High,
1482    /// 中負債(注意)
1483    Medium,
1484    /// 低負債(良好)
1485    Low,
1486}
1487
1488impl TechDebtLevel {
1489    /// 負債レベルの表示名
1490    pub fn display_name(&self) -> &'static str {
1491        match self {
1492            TechDebtLevel::High => "High Debt",
1493            TechDebtLevel::Medium => "Medium Debt",
1494            TechDebtLevel::Low => "Low Debt",
1495        }
1496    }
1497
1498    /// 負債レベルの色
1499    pub fn color(&self) -> &'static str {
1500        match self {
1501            TechDebtLevel::High => "red",
1502            TechDebtLevel::Medium => "yellow",
1503            TechDebtLevel::Low => "green",
1504        }
1505    }
1506}
1507
1508/// 技術的負債分析結果
1509#[derive(Debug, Clone, Default)]
1510pub struct TechDebtAnalysis {
1511    /// エントリ一覧(スコア降順)
1512    pub entries: Vec<TechDebtEntry>,
1513    /// 平均スコア
1514    pub avg_score: f64,
1515    /// 高負債ファイル数
1516    pub high_debt_count: usize,
1517    /// 分析されたファイル数
1518    pub total_files_analyzed: usize,
1519}
1520
1521impl TechDebtAnalysis {
1522    /// エントリ数を取得
1523    pub fn entry_count(&self) -> usize {
1524        self.entries.len()
1525    }
1526}
1527
1528/// 技術的負債スコアを計算
1529///
1530/// 技術的負債 = 変更頻度 × 複雑さ × 古さ
1531///
1532/// 計算式:
1533/// - 変更頻度スコア: (変更回数 / 最大変更回数)
1534/// - 複雑度スコア: (変更行数 / 最大変更行数)
1535/// - 年齢スコア: (経過日数 / 365) で上限1.0
1536pub fn calculate_tech_debt(
1537    events: &[&GitEvent],
1538    get_files: impl Fn(&str) -> Option<Vec<String>>,
1539    min_commits: usize,
1540) -> TechDebtAnalysis {
1541    use chrono::Local;
1542
1543    // ファイルごとの統計を集計
1544    let mut file_stats: HashMap<String, (usize, usize, DateTime<Local>)> = HashMap::new();
1545
1546    for event in events {
1547        if let Some(files) = get_files(&event.short_hash) {
1548            let changes_per_file = (event.files_added + event.files_deleted) / files.len().max(1);
1549            for file in files {
1550                let entry = file_stats.entry(file).or_insert((0, 0, event.timestamp));
1551                entry.0 += 1; // change_count
1552                entry.1 += changes_per_file; // total_changes
1553                                             // 最新の変更日時を保持
1554                if event.timestamp > entry.2 {
1555                    entry.2 = event.timestamp;
1556                }
1557            }
1558        }
1559    }
1560
1561    // 最大値を計算(正規化用)
1562    let max_changes = file_stats.values().map(|(c, _, _)| *c).max().unwrap_or(1);
1563    let max_lines = file_stats.values().map(|(_, l, _)| *l).max().unwrap_or(1);
1564    let now = Local::now();
1565
1566    let mut entries = Vec::new();
1567    let mut total_score = 0.0;
1568    let mut high_debt_count = 0;
1569
1570    for (path, (change_count, total_changes, last_modified)) in &file_stats {
1571        if *change_count < min_commits {
1572            continue;
1573        }
1574
1575        // 各スコアを計算
1576        let churn_score = *change_count as f64 / max_changes as f64;
1577        let complexity_score = *total_changes as f64 / max_lines as f64;
1578
1579        // 年齢スコア: 最後の変更からの経過日数
1580        let days_since_change = (now - *last_modified).num_days().max(0) as f64;
1581        let age_score = (days_since_change / 365.0).min(1.0);
1582
1583        // 総合スコア = 変更頻度(40%) + 複雑度(40%) + 年齢(20%)
1584        // ただし、年齢は「古くて頻繁に変更される」場合にリスクとなるため、
1585        // 年齢スコアは変更頻度と組み合わせて評価
1586        let score = churn_score * 0.5 + complexity_score * 0.4 + (churn_score * age_score) * 0.1;
1587
1588        let debt_level = if score >= 0.6 {
1589            high_debt_count += 1;
1590            TechDebtLevel::High
1591        } else if score >= 0.3 {
1592            TechDebtLevel::Medium
1593        } else {
1594            TechDebtLevel::Low
1595        };
1596
1597        entries.push(TechDebtEntry {
1598            path: path.clone(),
1599            score,
1600            churn_score,
1601            complexity_score,
1602            age_score,
1603            change_count: *change_count,
1604            total_changes: *total_changes,
1605            debt_level,
1606        });
1607
1608        total_score += score;
1609    }
1610
1611    // スコア降順でソート
1612    entries.sort_by(|a, b| {
1613        b.score
1614            .partial_cmp(&a.score)
1615            .unwrap_or(std::cmp::Ordering::Equal)
1616    });
1617
1618    let total_files_analyzed = entries.len();
1619    let avg_score = if total_files_analyzed > 0 {
1620        total_score / total_files_analyzed as f64
1621    } else {
1622        0.0
1623    };
1624
1625    TechDebtAnalysis {
1626        entries,
1627        avg_score,
1628        high_debt_count,
1629        total_files_analyzed,
1630    }
1631}
1632
1633// =============================================================================
1634// Project Health Dashboard
1635// =============================================================================
1636
1637/// アラートの深刻度
1638#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
1639pub enum AlertSeverity {
1640    /// 情報(青)
1641    Info,
1642    /// 警告(黄)
1643    Warning,
1644    /// 重大(赤)
1645    Critical,
1646}
1647
1648impl AlertSeverity {
1649    /// 色名を取得
1650    pub fn color(&self) -> &'static str {
1651        match self {
1652            AlertSeverity::Info => "blue",
1653            AlertSeverity::Warning => "yellow",
1654            AlertSeverity::Critical => "red",
1655        }
1656    }
1657
1658    /// アイコンを取得
1659    pub fn icon(&self) -> &'static str {
1660        match self {
1661            AlertSeverity::Info => "ℹ",
1662            AlertSeverity::Warning => "⚠",
1663            AlertSeverity::Critical => "🔴",
1664        }
1665    }
1666}
1667
1668/// ヘルスアラート
1669#[derive(Debug, Clone)]
1670pub struct HealthAlert {
1671    /// 深刻度
1672    pub severity: AlertSeverity,
1673    /// アラートメッセージ
1674    pub message: String,
1675    /// 詳細説明
1676    pub details: Option<String>,
1677}
1678
1679impl HealthAlert {
1680    /// 新規アラートを作成
1681    pub fn new(severity: AlertSeverity, message: impl Into<String>) -> Self {
1682        Self {
1683            severity,
1684            message: message.into(),
1685            details: None,
1686        }
1687    }
1688
1689    /// 詳細付きアラートを作成
1690    pub fn with_details(
1691        severity: AlertSeverity,
1692        message: impl Into<String>,
1693        details: impl Into<String>,
1694    ) -> Self {
1695        Self {
1696            severity,
1697            message: message.into(),
1698            details: Some(details.into()),
1699        }
1700    }
1701}
1702
1703/// 個別スコアコンポーネント
1704#[derive(Debug, Clone, Default)]
1705pub struct HealthScoreComponent {
1706    /// スコア (0.0〜1.0)
1707    pub score: f64,
1708    /// 重み (スコア計算時の割合)
1709    pub weight: f64,
1710    /// 説明
1711    pub description: String,
1712}
1713
1714/// プロジェクトヘルス分析結果
1715#[derive(Debug, Clone)]
1716pub struct ProjectHealth {
1717    /// 総合スコア (0〜100)
1718    pub overall_score: u8,
1719    /// 品質スコアコンポーネント (25%)
1720    pub quality: HealthScoreComponent,
1721    /// テスト健全性コンポーネント (25%)
1722    pub test_health: HealthScoreComponent,
1723    /// バスファクターリスクコンポーネント (25%)
1724    pub bus_factor_risk: HealthScoreComponent,
1725    /// 技術的負債コンポーネント (25%)
1726    pub tech_debt: HealthScoreComponent,
1727    /// アラート一覧(深刻度降順)
1728    pub alerts: Vec<HealthAlert>,
1729    /// 分析対象コミット数
1730    pub total_commits: usize,
1731    /// 分析対象著者数
1732    pub total_authors: usize,
1733    /// 分析期間(日数)
1734    pub analysis_period_days: u64,
1735}
1736
1737impl Default for ProjectHealth {
1738    fn default() -> Self {
1739        Self {
1740            overall_score: 50,
1741            quality: HealthScoreComponent {
1742                score: 0.5,
1743                weight: 0.25,
1744                description: "Commit quality average".to_string(),
1745            },
1746            test_health: HealthScoreComponent {
1747                score: 0.5,
1748                weight: 0.25,
1749                description: "Test commit ratio".to_string(),
1750            },
1751            bus_factor_risk: HealthScoreComponent {
1752                score: 0.5,
1753                weight: 0.25,
1754                description: "Knowledge concentration (lower is better)".to_string(),
1755            },
1756            tech_debt: HealthScoreComponent {
1757                score: 0.5,
1758                weight: 0.25,
1759                description: "Technical debt (lower is better)".to_string(),
1760            },
1761            alerts: Vec::new(),
1762            total_commits: 0,
1763            total_authors: 0,
1764            analysis_period_days: 0,
1765        }
1766    }
1767}
1768
1769impl ProjectHealth {
1770    /// 総合スコアのレベル文字列を取得
1771    pub fn level(&self) -> &'static str {
1772        if self.overall_score >= 80 {
1773            "Excellent"
1774        } else if self.overall_score >= 60 {
1775            "Good"
1776        } else if self.overall_score >= 40 {
1777            "Fair"
1778        } else {
1779            "Poor"
1780        }
1781    }
1782
1783    /// スコア色を取得
1784    pub fn score_color(&self) -> &'static str {
1785        if self.overall_score >= 80 {
1786            "green"
1787        } else if self.overall_score >= 60 {
1788            "cyan"
1789        } else if self.overall_score >= 40 {
1790            "yellow"
1791        } else {
1792            "red"
1793        }
1794    }
1795
1796    /// スコアバーを生成(10段階)
1797    pub fn score_bar(&self) -> String {
1798        let filled = (self.overall_score / 10) as usize;
1799        let empty = 10 - filled;
1800        format!("{}{}", "█".repeat(filled), "░".repeat(empty))
1801    }
1802}
1803
1804/// プロジェクトヘルスを計算
1805///
1806/// # Arguments
1807/// * `events` - 分析対象のGitイベント
1808/// * `files_fn` - ハッシュからファイル一覧を取得する関数
1809/// * `quality_analysis` - 事前に計算されたQuality分析結果(Optionalで計算)
1810/// * `bus_factor` - 事前に計算されたバスファクター分析結果(Optionalで計算)
1811/// * `tech_debt` - 事前に計算されたテクニカルデット分析結果(Optionalで計算)
1812pub fn calculate_project_health<F>(
1813    events: &[&GitEvent],
1814    files_fn: F,
1815    quality_analysis: Option<&CommitQualityAnalysis>,
1816    bus_factor: Option<&BusFactorAnalysis>,
1817    tech_debt_analysis: Option<&TechDebtAnalysis>,
1818) -> ProjectHealth
1819where
1820    F: Fn(&str) -> Option<Vec<String>>,
1821{
1822    if events.is_empty() {
1823        return ProjectHealth::default();
1824    }
1825
1826    let total_commits = events.len();
1827    let mut alerts = Vec::new();
1828
1829    // 著者を収集
1830    let authors: std::collections::HashSet<&str> =
1831        events.iter().map(|e| e.author.as_str()).collect();
1832    let total_authors = authors.len();
1833
1834    // 分析期間を計算
1835    let analysis_period_days = if events.len() >= 2 {
1836        let newest = events.first().map(|e| e.timestamp);
1837        let oldest = events.last().map(|e| e.timestamp);
1838        if let (Some(n), Some(o)) = (newest, oldest) {
1839            let duration = n.signed_duration_since(o);
1840            duration.num_days().unsigned_abs()
1841        } else {
1842            0
1843        }
1844    } else {
1845        0
1846    };
1847
1848    // 1. 品質スコア (25%)
1849    let quality_score = if let Some(qa) = quality_analysis {
1850        qa.avg_score
1851    } else {
1852        // 簡易計算: Conventional Commitsプレフィックスの比率
1853        let conventional_count = events
1854            .iter()
1855            .filter(|e| {
1856                let msg = e.message.to_lowercase();
1857                msg.starts_with("feat:")
1858                    || msg.starts_with("fix:")
1859                    || msg.starts_with("docs:")
1860                    || msg.starts_with("style:")
1861                    || msg.starts_with("refactor:")
1862                    || msg.starts_with("test:")
1863                    || msg.starts_with("chore:")
1864                    || msg.starts_with("perf:")
1865            })
1866            .count();
1867        conventional_count as f64 / total_commits as f64
1868    };
1869
1870    let quality = HealthScoreComponent {
1871        score: quality_score,
1872        weight: 0.25,
1873        description: format!(
1874            "Commit quality: {:.0}% conventional commits",
1875            quality_score * 100.0
1876        ),
1877    };
1878
1879    if quality_score < 0.3 {
1880        alerts.push(HealthAlert::with_details(
1881            AlertSeverity::Warning,
1882            "Low commit quality",
1883            "Consider using conventional commit messages (feat:, fix:, etc.)",
1884        ));
1885    }
1886
1887    // 2. テスト健全性 (25%)
1888    let test_commit_count = events
1889        .iter()
1890        .filter(|e| {
1891            let msg = e.message.to_lowercase();
1892            msg.starts_with("test:") || msg.contains("test") || msg.contains("spec")
1893        })
1894        .count();
1895    let test_file_commit_count = events
1896        .iter()
1897        .filter(|e| {
1898            if let Some(files) = files_fn(&e.short_hash) {
1899                files.iter().any(|f| {
1900                    f.contains("test")
1901                        || f.contains("spec")
1902                        || f.ends_with("_test.rs")
1903                        || f.ends_with(".test.")
1904                })
1905            } else {
1906                false
1907            }
1908        })
1909        .count();
1910    let test_ratio =
1911        (test_commit_count + test_file_commit_count) as f64 / (total_commits * 2) as f64;
1912    let test_score = test_ratio.min(1.0);
1913
1914    let test_health = HealthScoreComponent {
1915        score: test_score,
1916        weight: 0.25,
1917        description: format!(
1918            "Test coverage: {:.0}% test-related commits",
1919            test_score * 100.0
1920        ),
1921    };
1922
1923    if test_score < 0.1 {
1924        alerts.push(HealthAlert::with_details(
1925            AlertSeverity::Info,
1926            "Low test coverage in commits",
1927            "Consider adding more test commits or test-related changes",
1928        ));
1929    }
1930
1931    // 3. バスファクターリスク (25%) - 低いほど良い
1932    let bus_factor_score = if let Some(bf) = bus_factor {
1933        // バスファクターが高リスクのパスが多いほどスコアを下げる
1934        let low_risk_count = bf
1935            .total_paths_analyzed
1936            .saturating_sub(bf.high_risk_count + bf.medium_risk_count);
1937        let risk_ratio = bf.high_risk_count as f64
1938            / (bf.high_risk_count + bf.medium_risk_count + low_risk_count).max(1) as f64;
1939        1.0 - risk_ratio
1940    } else {
1941        // 簡易計算: 著者の分散を見る
1942        let mut author_commits: HashMap<&str, usize> = HashMap::new();
1943        for e in events.iter() {
1944            *author_commits.entry(e.author.as_str()).or_insert(0) += 1;
1945        }
1946        // 上位1人が全体の何%を占めるか
1947        let max_commits = author_commits.values().max().copied().unwrap_or(0);
1948        let concentration = max_commits as f64 / total_commits as f64;
1949        1.0 - concentration
1950    };
1951
1952    let bus_factor_risk = HealthScoreComponent {
1953        score: bus_factor_score,
1954        weight: 0.25,
1955        description: format!(
1956            "Knowledge distribution: {:.0}% (higher is better)",
1957            bus_factor_score * 100.0
1958        ),
1959    };
1960
1961    if bus_factor_score < 0.3 {
1962        alerts.push(HealthAlert::with_details(
1963            AlertSeverity::Critical,
1964            "High bus factor risk",
1965            "Knowledge is concentrated in few contributors. Consider knowledge sharing.",
1966        ));
1967    } else if bus_factor_score < 0.5 {
1968        alerts.push(HealthAlert::with_details(
1969            AlertSeverity::Warning,
1970            "Moderate bus factor risk",
1971            "Consider improving knowledge distribution across team members.",
1972        ));
1973    }
1974
1975    // 4. 技術的負債 (25%) - 低いほど良い
1976    let tech_debt_score = if let Some(td) = tech_debt_analysis {
1977        1.0 - td.avg_score.min(1.0)
1978    } else {
1979        // 簡易計算: 大きな変更の比率を見る
1980        let large_commits = events
1981            .iter()
1982            .filter(|e| e.files_added + e.files_deleted > 50)
1983            .count();
1984        let large_ratio = large_commits as f64 / total_commits as f64;
1985        1.0 - large_ratio.min(1.0)
1986    };
1987
1988    let tech_debt = HealthScoreComponent {
1989        score: tech_debt_score,
1990        weight: 0.25,
1991        description: format!(
1992            "Technical debt: {:.0}% clean (higher is better)",
1993            tech_debt_score * 100.0
1994        ),
1995    };
1996
1997    if tech_debt_score < 0.3 {
1998        alerts.push(HealthAlert::with_details(
1999            AlertSeverity::Warning,
2000            "High technical debt indicated",
2001            "Many large commits suggest accumulated technical debt.",
2002        ));
2003    }
2004
2005    // 総合スコアを計算
2006    let overall = quality.score * quality.weight
2007        + test_health.score * test_health.weight
2008        + bus_factor_risk.score * bus_factor_risk.weight
2009        + tech_debt.score * tech_debt.weight;
2010    let overall_score = (overall * 100.0).round() as u8;
2011
2012    // アラートを深刻度順にソート
2013    alerts.sort_by(|a, b| b.severity.cmp(&a.severity));
2014
2015    ProjectHealth {
2016        overall_score,
2017        quality,
2018        test_health,
2019        bus_factor_risk,
2020        tech_debt,
2021        alerts,
2022        total_commits,
2023        total_authors,
2024        analysis_period_days,
2025    }
2026}
2027
2028#[cfg(test)]
2029#[allow(clippy::useless_vec)]
2030mod tests {
2031    use super::*;
2032    use chrono::Local;
2033
2034    fn create_test_event(author: &str, insertions: usize, deletions: usize) -> GitEvent {
2035        GitEvent::commit(
2036            "abc1234".to_string(),
2037            "test commit".to_string(),
2038            author.to_string(),
2039            Local::now(),
2040            insertions,
2041            deletions,
2042        )
2043    }
2044
2045    #[test]
2046    fn test_calculate_stats_empty() {
2047        let stats = calculate_stats(&[]);
2048        assert_eq!(stats.total_commits, 0);
2049        assert_eq!(stats.authors.len(), 0);
2050    }
2051
2052    #[test]
2053    fn test_calculate_stats_single_author() {
2054        let events = vec![
2055            create_test_event("Alice", 10, 5),
2056            create_test_event("Alice", 20, 10),
2057        ];
2058        let refs: Vec<&GitEvent> = events.iter().collect();
2059        let stats = calculate_stats(&refs);
2060
2061        assert_eq!(stats.total_commits, 2);
2062        assert_eq!(stats.authors.len(), 1);
2063        assert_eq!(stats.authors[0].name, "Alice");
2064        assert_eq!(stats.authors[0].commit_count, 2);
2065        assert_eq!(stats.authors[0].insertions, 30);
2066        assert_eq!(stats.authors[0].deletions, 15);
2067    }
2068
2069    #[test]
2070    fn test_calculate_stats_multiple_authors() {
2071        let events = vec![
2072            create_test_event("Alice", 10, 5),
2073            create_test_event("Bob", 5, 2),
2074            create_test_event("Alice", 20, 10),
2075            create_test_event("Bob", 15, 8),
2076            create_test_event("Bob", 10, 5),
2077        ];
2078        let refs: Vec<&GitEvent> = events.iter().collect();
2079        let stats = calculate_stats(&refs);
2080
2081        assert_eq!(stats.total_commits, 5);
2082        assert_eq!(stats.authors.len(), 2);
2083
2084        // Bobが3コミットで最多なので先頭
2085        assert_eq!(stats.authors[0].name, "Bob");
2086        assert_eq!(stats.authors[0].commit_count, 3);
2087
2088        // Aliceが2コミット
2089        assert_eq!(stats.authors[1].name, "Alice");
2090        assert_eq!(stats.authors[1].commit_count, 2);
2091    }
2092
2093    #[test]
2094    fn test_calculate_stats_totals() {
2095        let events = vec![
2096            create_test_event("Alice", 10, 5),
2097            create_test_event("Bob", 20, 10),
2098        ];
2099        let refs: Vec<&GitEvent> = events.iter().collect();
2100        let stats = calculate_stats(&refs);
2101
2102        assert_eq!(stats.total_insertions, 30);
2103        assert_eq!(stats.total_deletions, 15);
2104    }
2105
2106    #[test]
2107    fn test_author_stats_commit_percentage() {
2108        let author = AuthorStats {
2109            name: "Alice".to_string(),
2110            commit_count: 25,
2111            insertions: 0,
2112            deletions: 0,
2113            last_commit: Local::now(),
2114        };
2115
2116        assert!((author.commit_percentage(100) - 25.0).abs() < 0.01);
2117        assert!((author.commit_percentage(50) - 50.0).abs() < 0.01);
2118    }
2119
2120    #[test]
2121    fn test_author_stats_commit_percentage_zero() {
2122        let author = AuthorStats {
2123            name: "Alice".to_string(),
2124            commit_count: 10,
2125            insertions: 0,
2126            deletions: 0,
2127            last_commit: Local::now(),
2128        };
2129
2130        assert_eq!(author.commit_percentage(0), 0.0);
2131    }
2132
2133    #[test]
2134    fn test_repo_stats_author_count() {
2135        let events = vec![
2136            create_test_event("Alice", 10, 5),
2137            create_test_event("Bob", 5, 2),
2138            create_test_event("Charlie", 15, 8),
2139        ];
2140        let refs: Vec<&GitEvent> = events.iter().collect();
2141        let stats = calculate_stats(&refs);
2142
2143        assert_eq!(stats.author_count(), 3);
2144    }
2145
2146    // ===== ヒートマップのテスト =====
2147
2148    fn create_test_event_with_hash(hash: &str) -> GitEvent {
2149        GitEvent::commit(
2150            hash.to_string(),
2151            "test commit".to_string(),
2152            "author".to_string(),
2153            Local::now(),
2154            10,
2155            5,
2156        )
2157    }
2158
2159    #[test]
2160    fn test_calculate_file_heatmap_empty() {
2161        let events: Vec<&GitEvent> = vec![];
2162        let heatmap = calculate_file_heatmap(&events, |_| None);
2163        assert_eq!(heatmap.file_count(), 0);
2164    }
2165
2166    #[test]
2167    fn test_calculate_file_heatmap_single_file() {
2168        let events = vec![
2169            create_test_event_with_hash("abc1234"),
2170            create_test_event_with_hash("def5678"),
2171        ];
2172        let refs: Vec<&GitEvent> = events.iter().collect();
2173        let heatmap = calculate_file_heatmap(&refs, |_| Some(vec!["src/main.rs".to_string()]));
2174
2175        assert_eq!(heatmap.file_count(), 1);
2176        assert_eq!(heatmap.files[0].path, "src/main.rs");
2177        assert_eq!(heatmap.files[0].change_count, 2);
2178    }
2179
2180    #[test]
2181    fn test_calculate_file_heatmap_multiple_files() {
2182        let events = vec![
2183            create_test_event_with_hash("abc1234"),
2184            create_test_event_with_hash("def5678"),
2185            create_test_event_with_hash("ghi9012"),
2186        ];
2187        let refs: Vec<&GitEvent> = events.iter().collect();
2188        let heatmap = calculate_file_heatmap(&refs, |hash| match hash {
2189            "abc1234" => Some(vec!["src/a.rs".to_string(), "src/b.rs".to_string()]),
2190            "def5678" => Some(vec!["src/a.rs".to_string()]),
2191            "ghi9012" => Some(vec!["src/a.rs".to_string(), "src/c.rs".to_string()]),
2192            _ => None,
2193        });
2194
2195        assert_eq!(heatmap.file_count(), 3);
2196        // src/a.rsが3回で最多
2197        assert_eq!(heatmap.files[0].path, "src/a.rs");
2198        assert_eq!(heatmap.files[0].change_count, 3);
2199    }
2200
2201    #[test]
2202    fn test_file_heatmap_entry_heat_level() {
2203        let entry = FileHeatmapEntry {
2204            path: "test.rs".to_string(),
2205            change_count: 5,
2206            max_changes: 10,
2207        };
2208        assert!((entry.heat_level() - 0.5).abs() < 0.01);
2209    }
2210
2211    #[test]
2212    fn test_file_heatmap_entry_heat_bar() {
2213        let entry_high = FileHeatmapEntry {
2214            path: "test.rs".to_string(),
2215            change_count: 10,
2216            max_changes: 10,
2217        };
2218        assert_eq!(entry_high.heat_bar(), "█████");
2219
2220        let entry_low = FileHeatmapEntry {
2221            path: "test.rs".to_string(),
2222            change_count: 1,
2223            max_changes: 10,
2224        };
2225        assert_eq!(entry_low.heat_bar(), "█    ");
2226    }
2227
2228    // ===== AggregationLevel テスト =====
2229
2230    #[test]
2231    fn test_aggregation_level_next() {
2232        assert_eq!(AggregationLevel::Files.next(), AggregationLevel::Shallow);
2233        assert_eq!(AggregationLevel::Shallow.next(), AggregationLevel::Deep);
2234        assert_eq!(AggregationLevel::Deep.next(), AggregationLevel::Files);
2235    }
2236
2237    #[test]
2238    fn test_aggregation_level_prev() {
2239        assert_eq!(AggregationLevel::Files.prev(), AggregationLevel::Deep);
2240        assert_eq!(AggregationLevel::Shallow.prev(), AggregationLevel::Files);
2241        assert_eq!(AggregationLevel::Deep.prev(), AggregationLevel::Shallow);
2242    }
2243
2244    #[test]
2245    fn test_aggregation_level_display_name() {
2246        assert_eq!(AggregationLevel::Files.display_name(), "Files");
2247        assert!(AggregationLevel::Shallow
2248            .display_name()
2249            .contains("2 levels"));
2250        assert!(AggregationLevel::Deep.display_name().contains("top level"));
2251    }
2252
2253    #[test]
2254    fn test_heatmap_with_aggregation_shallow() {
2255        let heatmap = FileHeatmap {
2256            files: vec![
2257                FileHeatmapEntry {
2258                    path: "src/auth/login.rs".to_string(),
2259                    change_count: 10,
2260                    max_changes: 10,
2261                },
2262                FileHeatmapEntry {
2263                    path: "src/auth/token.rs".to_string(),
2264                    change_count: 5,
2265                    max_changes: 10,
2266                },
2267                FileHeatmapEntry {
2268                    path: "src/api/user.rs".to_string(),
2269                    change_count: 3,
2270                    max_changes: 10,
2271                },
2272            ],
2273            total_files: 3,
2274            aggregation_level: AggregationLevel::Files,
2275        };
2276
2277        let aggregated = heatmap.with_aggregation(AggregationLevel::Shallow);
2278        assert_eq!(aggregated.aggregation_level, AggregationLevel::Shallow);
2279        assert_eq!(aggregated.files.len(), 2); // src/auth/ と src/api/
2280
2281        // src/auth/が最多(10+5=15)
2282        assert_eq!(aggregated.files[0].path, "src/auth/");
2283        assert_eq!(aggregated.files[0].change_count, 15);
2284    }
2285
2286    #[test]
2287    fn test_heatmap_with_aggregation_deep() {
2288        let heatmap = FileHeatmap {
2289            files: vec![
2290                FileHeatmapEntry {
2291                    path: "src/auth/login.rs".to_string(),
2292                    change_count: 10,
2293                    max_changes: 10,
2294                },
2295                FileHeatmapEntry {
2296                    path: "src/api/user.rs".to_string(),
2297                    change_count: 5,
2298                    max_changes: 10,
2299                },
2300                FileHeatmapEntry {
2301                    path: "tests/test.rs".to_string(),
2302                    change_count: 3,
2303                    max_changes: 10,
2304                },
2305            ],
2306            total_files: 3,
2307            aggregation_level: AggregationLevel::Files,
2308        };
2309
2310        let aggregated = heatmap.with_aggregation(AggregationLevel::Deep);
2311        assert_eq!(aggregated.aggregation_level, AggregationLevel::Deep);
2312        assert_eq!(aggregated.files.len(), 2); // src/ と tests/
2313
2314        // src/が最多(10+5=15)
2315        assert_eq!(aggregated.files[0].path, "src/");
2316        assert_eq!(aggregated.files[0].change_count, 15);
2317    }
2318
2319    // ===== Impact Score テスト =====
2320
2321    fn create_test_event_with_hash_changes(
2322        hash: &str,
2323        author: &str,
2324        insertions: usize,
2325        deletions: usize,
2326    ) -> GitEvent {
2327        GitEvent::commit(
2328            hash.to_string(),
2329            "test commit".to_string(),
2330            author.to_string(),
2331            Local::now(),
2332            insertions,
2333            deletions,
2334        )
2335    }
2336
2337    #[test]
2338    fn test_calculate_impact_scores_empty() {
2339        let events: Vec<&GitEvent> = vec![];
2340        let heatmap = FileHeatmap::default();
2341        let analysis = calculate_impact_scores(&events, |_| None, &heatmap);
2342        assert_eq!(analysis.total_commits, 0);
2343        assert_eq!(analysis.commits.len(), 0);
2344        assert_eq!(analysis.avg_score, 0.0);
2345    }
2346
2347    #[test]
2348    fn test_calculate_impact_scores_single_commit() {
2349        let events = vec![create_test_event_with_hash_changes(
2350            "abc1234", "Alice", 100, 50,
2351        )];
2352        let refs: Vec<&GitEvent> = events.iter().collect();
2353        let heatmap = FileHeatmap::default();
2354
2355        let analysis =
2356            calculate_impact_scores(&refs, |_| Some(vec!["src/main.rs".to_string()]), &heatmap);
2357
2358        assert_eq!(analysis.total_commits, 1);
2359        assert_eq!(analysis.commits.len(), 1);
2360
2361        let commit = &analysis.commits[0];
2362        assert_eq!(commit.commit_hash, "abc1234");
2363        assert_eq!(commit.files_changed, 1);
2364        assert_eq!(commit.insertions, 100);
2365        assert_eq!(commit.deletions, 50);
2366    }
2367
2368    #[test]
2369    fn test_calculate_impact_scores_file_score() {
2370        // 50ファイル変更 → file_score = 0.4
2371        let events = vec![create_test_event_with_hash_changes(
2372            "abc1234", "Alice", 10, 5,
2373        )];
2374        let refs: Vec<&GitEvent> = events.iter().collect();
2375        let heatmap = FileHeatmap::default();
2376
2377        // 50ファイルを返す
2378        let files: Vec<String> = (0..50).map(|i| format!("file{}.rs", i)).collect();
2379        let analysis = calculate_impact_scores(&refs, |_| Some(files.clone()), &heatmap);
2380
2381        let commit = &analysis.commits[0];
2382        assert!((commit.file_score - 0.4).abs() < 0.01);
2383    }
2384
2385    #[test]
2386    fn test_calculate_impact_scores_change_score() {
2387        // 500行変更 → change_score = 0.4
2388        let events = vec![create_test_event_with_hash_changes(
2389            "abc1234", "Alice", 250, 250,
2390        )];
2391        let refs: Vec<&GitEvent> = events.iter().collect();
2392        let heatmap = FileHeatmap::default();
2393
2394        let analysis =
2395            calculate_impact_scores(&refs, |_| Some(vec!["src/main.rs".to_string()]), &heatmap);
2396
2397        let commit = &analysis.commits[0];
2398        assert!((commit.change_score - 0.4).abs() < 0.01);
2399    }
2400
2401    #[test]
2402    fn test_calculate_impact_scores_sorted_by_score_desc() {
2403        let events = vec![
2404            create_test_event_with_hash_changes("low", "Alice", 10, 5), // 低スコア
2405            create_test_event_with_hash_changes("high", "Bob", 400, 100), // 高スコア
2406            create_test_event_with_hash_changes("medium", "Carol", 100, 50), // 中スコア
2407        ];
2408        let refs: Vec<&GitEvent> = events.iter().collect();
2409        let heatmap = FileHeatmap::default();
2410
2411        let analysis =
2412            calculate_impact_scores(&refs, |_| Some(vec!["src/main.rs".to_string()]), &heatmap);
2413
2414        assert_eq!(analysis.commits.len(), 3);
2415        // スコア降順でソートされている
2416        assert_eq!(analysis.commits[0].commit_hash, "high");
2417        assert_eq!(analysis.commits[1].commit_hash, "medium");
2418        assert_eq!(analysis.commits[2].commit_hash, "low");
2419    }
2420
2421    #[test]
2422    fn test_commit_impact_score_color_high() {
2423        let commit = CommitImpactScore {
2424            commit_hash: "abc".to_string(),
2425            commit_message: "test".to_string(),
2426            author: "Alice".to_string(),
2427            date: Local::now(),
2428            files_changed: 0,
2429            insertions: 0,
2430            deletions: 0,
2431            score: 0.8,
2432            file_score: 0.0,
2433            change_score: 0.0,
2434            heat_score: 0.0,
2435        };
2436        assert_eq!(commit.score_color(), "red");
2437    }
2438
2439    #[test]
2440    fn test_commit_impact_score_color_medium() {
2441        let commit = CommitImpactScore {
2442            commit_hash: "abc".to_string(),
2443            commit_message: "test".to_string(),
2444            author: "Alice".to_string(),
2445            date: Local::now(),
2446            files_changed: 0,
2447            insertions: 0,
2448            deletions: 0,
2449            score: 0.5,
2450            file_score: 0.0,
2451            change_score: 0.0,
2452            heat_score: 0.0,
2453        };
2454        assert_eq!(commit.score_color(), "yellow");
2455    }
2456
2457    #[test]
2458    fn test_commit_impact_score_color_low() {
2459        let commit = CommitImpactScore {
2460            commit_hash: "abc".to_string(),
2461            commit_message: "test".to_string(),
2462            author: "Alice".to_string(),
2463            date: Local::now(),
2464            files_changed: 0,
2465            insertions: 0,
2466            deletions: 0,
2467            score: 0.2,
2468            file_score: 0.0,
2469            change_score: 0.0,
2470            heat_score: 0.0,
2471        };
2472        assert_eq!(commit.score_color(), "green");
2473    }
2474
2475    #[test]
2476    fn test_commit_impact_score_bar() {
2477        let mut commit = CommitImpactScore {
2478            commit_hash: "abc".to_string(),
2479            commit_message: "test".to_string(),
2480            author: "Alice".to_string(),
2481            date: Local::now(),
2482            files_changed: 0,
2483            insertions: 0,
2484            deletions: 0,
2485            score: 0.0,
2486            file_score: 0.0,
2487            change_score: 0.0,
2488            heat_score: 0.0,
2489        };
2490
2491        commit.score = 0.9;
2492        assert_eq!(commit.score_bar(), "█████");
2493
2494        commit.score = 0.7;
2495        assert_eq!(commit.score_bar(), "████ ");
2496
2497        commit.score = 0.5;
2498        assert_eq!(commit.score_bar(), "███  ");
2499
2500        commit.score = 0.3;
2501        assert_eq!(commit.score_bar(), "██   ");
2502
2503        commit.score = 0.1;
2504        assert_eq!(commit.score_bar(), "█    ");
2505    }
2506
2507    #[test]
2508    fn test_calculate_impact_scores_high_impact_count() {
2509        let events = vec![
2510            create_test_event_with_hash_changes("a", "Alice", 400, 100), // high (score >= 0.7)
2511            create_test_event_with_hash_changes("b", "Bob", 300, 100),   // medium
2512            create_test_event_with_hash_changes("c", "Carol", 10, 5),    // low
2513        ];
2514        let refs: Vec<&GitEvent> = events.iter().collect();
2515        let heatmap = FileHeatmap::default();
2516
2517        // 多くのファイルを変更して高スコアに
2518        let analysis = calculate_impact_scores(
2519            &refs,
2520            |hash| {
2521                if hash == "a" {
2522                    Some((0..50).map(|i| format!("file{}.rs", i)).collect())
2523                } else {
2524                    Some(vec!["file.rs".to_string()])
2525                }
2526            },
2527            &heatmap,
2528        );
2529
2530        assert!(analysis.high_impact_count >= 1);
2531    }
2532
2533    // ===== Change Coupling テスト =====
2534
2535    #[test]
2536    fn test_calculate_change_coupling_empty() {
2537        let events: Vec<&GitEvent> = vec![];
2538        let analysis = calculate_change_coupling(&events, |_| None, 5, 0.3);
2539        assert_eq!(analysis.coupling_count(), 0);
2540        assert_eq!(analysis.high_coupling_count, 0);
2541        assert_eq!(analysis.total_files_analyzed, 0);
2542    }
2543
2544    #[test]
2545    fn test_calculate_change_coupling_single_file() {
2546        // 単一ファイルのコミットではペアが作れないので結合なし
2547        let events = vec![
2548            create_test_event_with_hash("commit1"),
2549            create_test_event_with_hash("commit2"),
2550            create_test_event_with_hash("commit3"),
2551            create_test_event_with_hash("commit4"),
2552            create_test_event_with_hash("commit5"),
2553        ];
2554        let refs: Vec<&GitEvent> = events.iter().collect();
2555
2556        let analysis =
2557            calculate_change_coupling(&refs, |_| Some(vec!["src/main.rs".to_string()]), 1, 0.0);
2558        assert_eq!(analysis.coupling_count(), 0);
2559    }
2560
2561    #[test]
2562    fn test_calculate_change_coupling_pair() {
2563        // 2ファイルが常に一緒に変更される場合
2564        let events = vec![
2565            create_test_event_with_hash("commit1"),
2566            create_test_event_with_hash("commit2"),
2567            create_test_event_with_hash("commit3"),
2568            create_test_event_with_hash("commit4"),
2569            create_test_event_with_hash("commit5"),
2570        ];
2571        let refs: Vec<&GitEvent> = events.iter().collect();
2572
2573        let analysis = calculate_change_coupling(
2574            &refs,
2575            |_| Some(vec!["src/app.rs".to_string(), "src/ui.rs".to_string()]),
2576            5,
2577            0.3,
2578        );
2579
2580        // A→B と B→A の両方が記録される
2581        assert_eq!(analysis.coupling_count(), 2);
2582
2583        // 両方とも100%の結合度
2584        for coupling in &analysis.couplings {
2585            assert!((coupling.coupling_percent - 1.0).abs() < 0.01);
2586            assert_eq!(coupling.co_change_count, 5);
2587            assert_eq!(coupling.file_change_count, 5);
2588        }
2589    }
2590
2591    #[test]
2592    fn test_calculate_change_coupling_partial() {
2593        // 部分的に一緒に変更されるファイル
2594        let events = vec![
2595            create_test_event_with_hash("commit1"),
2596            create_test_event_with_hash("commit2"),
2597            create_test_event_with_hash("commit3"),
2598            create_test_event_with_hash("commit4"),
2599            create_test_event_with_hash("commit5"),
2600            create_test_event_with_hash("commit6"),
2601            create_test_event_with_hash("commit7"),
2602            create_test_event_with_hash("commit8"),
2603            create_test_event_with_hash("commit9"),
2604            create_test_event_with_hash("commit10"),
2605        ];
2606        let refs: Vec<&GitEvent> = events.iter().collect();
2607
2608        let analysis = calculate_change_coupling(
2609            &refs,
2610            |hash| {
2611                // 最初の8コミットはapp.rsとui.rsを変更
2612                // 残り2コミットはapp.rsのみ変更
2613                if hash == "commit9" || hash == "commit10" {
2614                    Some(vec!["src/app.rs".to_string()])
2615                } else {
2616                    Some(vec!["src/app.rs".to_string(), "src/ui.rs".to_string()])
2617                }
2618            },
2619            5,
2620            0.3,
2621        );
2622
2623        // app.rs→ui.rsの結合度は80%(8/10)
2624        let app_to_ui = analysis
2625            .couplings
2626            .iter()
2627            .find(|c| c.file == "src/app.rs" && c.coupled_file == "src/ui.rs");
2628        assert!(app_to_ui.is_some());
2629        let coupling = app_to_ui.unwrap();
2630        assert!((coupling.coupling_percent - 0.8).abs() < 0.01);
2631
2632        // ui.rs→app.rsの結合度は100%(8/8)
2633        let ui_to_app = analysis
2634            .couplings
2635            .iter()
2636            .find(|c| c.file == "src/ui.rs" && c.coupled_file == "src/app.rs");
2637        assert!(ui_to_app.is_some());
2638        let coupling = ui_to_app.unwrap();
2639        assert!((coupling.coupling_percent - 1.0).abs() < 0.01);
2640    }
2641
2642    #[test]
2643    fn test_calculate_change_coupling_min_commits_filter() {
2644        let events = vec![
2645            create_test_event_with_hash("commit1"),
2646            create_test_event_with_hash("commit2"),
2647            create_test_event_with_hash("commit3"),
2648        ];
2649        let refs: Vec<&GitEvent> = events.iter().collect();
2650
2651        // min_commits=5なので、3コミットのファイルは除外される
2652        let analysis = calculate_change_coupling(
2653            &refs,
2654            |_| Some(vec!["src/app.rs".to_string(), "src/ui.rs".to_string()]),
2655            5,
2656            0.3,
2657        );
2658
2659        assert_eq!(analysis.coupling_count(), 0);
2660    }
2661
2662    #[test]
2663    fn test_calculate_change_coupling_min_coupling_filter() {
2664        let events = vec![
2665            create_test_event_with_hash("commit1"),
2666            create_test_event_with_hash("commit2"),
2667            create_test_event_with_hash("commit3"),
2668            create_test_event_with_hash("commit4"),
2669            create_test_event_with_hash("commit5"),
2670        ];
2671        let refs: Vec<&GitEvent> = events.iter().collect();
2672
2673        let analysis = calculate_change_coupling(
2674            &refs,
2675            |hash| {
2676                // commit1だけapp.rsとui.rsを変更(20%の結合度)
2677                if hash == "commit1" {
2678                    Some(vec!["src/app.rs".to_string(), "src/ui.rs".to_string()])
2679                } else {
2680                    Some(vec!["src/app.rs".to_string()])
2681                }
2682            },
2683            1,
2684            0.3, // 30%未満は除外
2685        );
2686
2687        // app.rs→ui.rsは20%なので除外される
2688        let app_to_ui = analysis
2689            .couplings
2690            .iter()
2691            .find(|c| c.file == "src/app.rs" && c.coupled_file == "src/ui.rs");
2692        assert!(app_to_ui.is_none());
2693    }
2694
2695    #[test]
2696    fn test_calculate_change_coupling_high_coupling_count() {
2697        let events = vec![
2698            create_test_event_with_hash("commit1"),
2699            create_test_event_with_hash("commit2"),
2700            create_test_event_with_hash("commit3"),
2701            create_test_event_with_hash("commit4"),
2702            create_test_event_with_hash("commit5"),
2703        ];
2704        let refs: Vec<&GitEvent> = events.iter().collect();
2705
2706        // 100%の結合度(高結合)
2707        let analysis = calculate_change_coupling(
2708            &refs,
2709            |_| Some(vec!["src/app.rs".to_string(), "src/ui.rs".to_string()]),
2710            5,
2711            0.3,
2712        );
2713
2714        // A→BとB→Aで2つだが、重複を避けて1としてカウント
2715        assert_eq!(analysis.high_coupling_count, 1);
2716    }
2717
2718    #[test]
2719    fn test_file_coupling_bar() {
2720        let coupling = FileCoupling {
2721            file: "src/app.rs".to_string(),
2722            coupled_file: "src/ui.rs".to_string(),
2723            co_change_count: 8,
2724            file_change_count: 10,
2725            coupling_percent: 0.8,
2726        };
2727
2728        // 80%なので8個の█と2個の░
2729        assert_eq!(coupling.coupling_bar(), "[████████░░]");
2730    }
2731
2732    #[test]
2733    fn test_change_coupling_grouped_by_file() {
2734        let analysis = ChangeCouplingAnalysis {
2735            couplings: vec![
2736                FileCoupling {
2737                    file: "src/app.rs".to_string(),
2738                    coupled_file: "src/ui.rs".to_string(),
2739                    co_change_count: 8,
2740                    file_change_count: 10,
2741                    coupling_percent: 0.8,
2742                },
2743                FileCoupling {
2744                    file: "src/app.rs".to_string(),
2745                    coupled_file: "src/main.rs".to_string(),
2746                    co_change_count: 5,
2747                    file_change_count: 10,
2748                    coupling_percent: 0.5,
2749                },
2750                FileCoupling {
2751                    file: "src/git.rs".to_string(),
2752                    coupled_file: "src/filter.rs".to_string(),
2753                    co_change_count: 3,
2754                    file_change_count: 5,
2755                    coupling_percent: 0.6,
2756                },
2757            ],
2758            high_coupling_count: 1,
2759            total_files_analyzed: 5,
2760        };
2761
2762        let grouped = analysis.grouped_by_file();
2763        assert_eq!(grouped.len(), 2);
2764
2765        // app.rs(10コミット)が先頭
2766        assert_eq!(grouped[0].0, "src/app.rs");
2767        assert_eq!(grouped[0].1.len(), 2);
2768
2769        // git.rs(5コミット)が次
2770        assert_eq!(grouped[1].0, "src/git.rs");
2771        assert_eq!(grouped[1].1.len(), 1);
2772    }
2773
2774    // ===== Quality Score テスト =====
2775
2776    #[test]
2777    fn test_calculate_message_quality_conventional() {
2778        // Conventional Commit形式のメッセージ
2779        let score = calculate_message_quality("feat: add new feature for user authentication");
2780        assert!(score >= 0.7, "Expected >= 0.7, got {}", score);
2781    }
2782
2783    #[test]
2784    fn test_calculate_message_quality_conventional_fix() {
2785        let score = calculate_message_quality("fix: resolve memory leak in connection pool");
2786        assert!(score >= 0.7, "Expected >= 0.7, got {}", score);
2787    }
2788
2789    #[test]
2790    fn test_calculate_message_quality_non_conventional() {
2791        // Conventional形式でないが意味のあるメッセージ
2792        let score = calculate_message_quality("Add user authentication feature");
2793        assert!(
2794            (0.3..0.7).contains(&score),
2795            "Expected 0.3-0.7, got {}",
2796            score
2797        );
2798    }
2799
2800    #[test]
2801    fn test_calculate_message_quality_short() {
2802        // 短すぎるメッセージ
2803        let score = calculate_message_quality("fix");
2804        assert!(score < 0.3, "Expected < 0.3, got {}", score);
2805    }
2806
2807    #[test]
2808    fn test_calculate_message_quality_empty() {
2809        let score = calculate_message_quality("");
2810        assert_eq!(score, 0.0);
2811    }
2812
2813    #[test]
2814    fn test_calculate_size_appropriateness_ideal() {
2815        // 理想的なサイズ(3ファイル、100行変更)
2816        let score = calculate_size_appropriateness(3, 60, 40);
2817        assert!(score >= 0.8, "Expected >= 0.8, got {}", score);
2818    }
2819
2820    #[test]
2821    fn test_calculate_size_appropriateness_large() {
2822        // 大きすぎるコミット(20ファイル、1000行変更)
2823        let score = calculate_size_appropriateness(20, 800, 200);
2824        assert!(score <= 0.2, "Expected <= 0.2, got {}", score);
2825    }
2826
2827    #[test]
2828    fn test_calculate_size_appropriateness_empty() {
2829        // 空のコミット
2830        let score = calculate_size_appropriateness(0, 0, 0);
2831        assert_eq!(score, 0.0);
2832    }
2833
2834    #[test]
2835    fn test_calculate_test_presence_both() {
2836        // ソースとテスト両方
2837        let files = vec![
2838            "src/main.rs".to_string(),
2839            "src/lib.rs".to_string(),
2840            "tests/test_main.rs".to_string(),
2841        ];
2842        let score = calculate_test_presence(&files);
2843        assert_eq!(score, 1.0);
2844    }
2845
2846    #[test]
2847    fn test_calculate_test_presence_source_only() {
2848        // ソースのみ
2849        let files = vec!["src/main.rs".to_string(), "src/lib.rs".to_string()];
2850        let score = calculate_test_presence(&files);
2851        assert!(score < 0.5, "Expected < 0.5, got {}", score);
2852    }
2853
2854    #[test]
2855    fn test_calculate_test_presence_test_only() {
2856        // テストのみ
2857        let files = vec![
2858            "tests/test_main.rs".to_string(),
2859            "src/module_test.rs".to_string(),
2860        ];
2861        let score = calculate_test_presence(&files);
2862        assert!(score >= 0.7, "Expected >= 0.7, got {}", score);
2863    }
2864
2865    #[test]
2866    fn test_calculate_test_presence_empty() {
2867        let score = calculate_test_presence(&[]);
2868        assert_eq!(score, 0.0);
2869    }
2870
2871    #[test]
2872    fn test_calculate_atomicity_single_file() {
2873        let files = vec!["src/main.rs".to_string()];
2874        let coupling = ChangeCouplingAnalysis::default();
2875        let score = calculate_atomicity(&files, &coupling);
2876        assert_eq!(score, 1.0);
2877    }
2878
2879    #[test]
2880    fn test_calculate_atomicity_same_directory() {
2881        let files = vec![
2882            "src/app.rs".to_string(),
2883            "src/lib.rs".to_string(),
2884            "src/main.rs".to_string(),
2885        ];
2886        let coupling = ChangeCouplingAnalysis::default();
2887        let score = calculate_atomicity(&files, &coupling);
2888        assert!(score >= 0.5, "Expected >= 0.5, got {}", score);
2889    }
2890
2891    #[test]
2892    fn test_calculate_atomicity_scattered() {
2893        let files = vec![
2894            "src/app.rs".to_string(),
2895            "tests/test.rs".to_string(),
2896            "docs/readme.md".to_string(),
2897            "config/settings.toml".to_string(),
2898        ];
2899        let coupling = ChangeCouplingAnalysis::default();
2900        let score = calculate_atomicity(&files, &coupling);
2901        assert!(score < 0.5, "Expected < 0.5, got {}", score);
2902    }
2903
2904    #[test]
2905    fn test_calculate_quality_scores_empty() {
2906        let events: Vec<&GitEvent> = vec![];
2907        let coupling = ChangeCouplingAnalysis::default();
2908        let analysis = calculate_quality_scores(&events, |_| None, &coupling);
2909        assert_eq!(analysis.total_commits, 0);
2910        assert_eq!(analysis.commits.len(), 0);
2911        assert_eq!(analysis.avg_score, 0.0);
2912    }
2913
2914    fn create_test_event_for_quality(
2915        hash: &str,
2916        message: &str,
2917        insertions: usize,
2918        deletions: usize,
2919    ) -> GitEvent {
2920        GitEvent::commit(
2921            hash.to_string(),
2922            message.to_string(),
2923            "author".to_string(),
2924            Local::now(),
2925            insertions,
2926            deletions,
2927        )
2928    }
2929
2930    #[test]
2931    fn test_calculate_quality_scores_single_commit() {
2932        let events = vec![create_test_event_for_quality(
2933            "abc1234",
2934            "feat: add authentication feature",
2935            50,
2936            10,
2937        )];
2938        let refs: Vec<&GitEvent> = events.iter().collect();
2939        let coupling = ChangeCouplingAnalysis::default();
2940
2941        let analysis = calculate_quality_scores(
2942            &refs,
2943            |_| {
2944                Some(vec![
2945                    "src/auth.rs".to_string(),
2946                    "tests/auth_test.rs".to_string(),
2947                ])
2948            },
2949            &coupling,
2950        );
2951
2952        assert_eq!(analysis.total_commits, 1);
2953        assert_eq!(analysis.commits.len(), 1);
2954
2955        let commit = &analysis.commits[0];
2956        assert_eq!(commit.commit_hash, "abc1234");
2957        assert!(commit.score > 0.0);
2958        assert!(commit.message_score > 0.0);
2959        assert!(commit.size_score > 0.0);
2960        assert!(commit.test_score > 0.0);
2961    }
2962
2963    #[test]
2964    fn test_calculate_quality_scores_sorted_by_score() {
2965        let events = vec![
2966            create_test_event_for_quality("low", "wip", 500, 500), // 低品質
2967            create_test_event_for_quality("high", "feat: excellent commit with tests", 50, 20), // 高品質
2968            create_test_event_for_quality("medium", "update files", 100, 50), // 中品質
2969        ];
2970        let refs: Vec<&GitEvent> = events.iter().collect();
2971        let coupling = ChangeCouplingAnalysis::default();
2972
2973        let analysis = calculate_quality_scores(
2974            &refs,
2975            |hash| {
2976                if hash == "high" {
2977                    Some(vec![
2978                        "src/feature.rs".to_string(),
2979                        "tests/feature_test.rs".to_string(),
2980                    ])
2981                } else {
2982                    Some(vec!["src/main.rs".to_string()])
2983                }
2984            },
2985            &coupling,
2986        );
2987
2988        assert_eq!(analysis.commits.len(), 3);
2989        // スコア降順でソートされている
2990        assert!(analysis.commits[0].score >= analysis.commits[1].score);
2991        assert!(analysis.commits[1].score >= analysis.commits[2].score);
2992    }
2993
2994    #[test]
2995    fn test_commit_quality_score_color() {
2996        let mut commit = CommitQualityScore {
2997            commit_hash: "abc".to_string(),
2998            commit_message: "test".to_string(),
2999            author: "author".to_string(),
3000            date: Local::now(),
3001            files_changed: 0,
3002            insertions: 0,
3003            deletions: 0,
3004            score: 0.0,
3005            message_score: 0.0,
3006            size_score: 0.0,
3007            test_score: 0.0,
3008            atomicity_score: 0.0,
3009        };
3010
3011        // >= 0.6 は green(Excellent/Good)
3012        commit.score = 0.9;
3013        assert_eq!(commit.score_color(), "green");
3014
3015        commit.score = 0.7;
3016        assert_eq!(commit.score_color(), "green");
3017
3018        commit.score = 0.6;
3019        assert_eq!(commit.score_color(), "green");
3020
3021        // 0.4-0.6 は yellow(Fair)
3022        commit.score = 0.5;
3023        assert_eq!(commit.score_color(), "yellow");
3024
3025        commit.score = 0.4;
3026        assert_eq!(commit.score_color(), "yellow");
3027
3028        // < 0.4 は red(Poor)
3029        commit.score = 0.3;
3030        assert_eq!(commit.score_color(), "red");
3031
3032        commit.score = 0.2;
3033        assert_eq!(commit.score_color(), "red");
3034    }
3035
3036    #[test]
3037    fn test_commit_quality_score_bar() {
3038        let mut commit = CommitQualityScore {
3039            commit_hash: "abc".to_string(),
3040            commit_message: "test".to_string(),
3041            author: "author".to_string(),
3042            date: Local::now(),
3043            files_changed: 0,
3044            insertions: 0,
3045            deletions: 0,
3046            score: 0.0,
3047            message_score: 0.0,
3048            size_score: 0.0,
3049            test_score: 0.0,
3050            atomicity_score: 0.0,
3051        };
3052
3053        commit.score = 0.9;
3054        assert_eq!(commit.score_bar(), "█████");
3055
3056        commit.score = 0.7;
3057        assert_eq!(commit.score_bar(), "████ ");
3058
3059        commit.score = 0.5;
3060        assert_eq!(commit.score_bar(), "███  ");
3061
3062        commit.score = 0.3;
3063        assert_eq!(commit.score_bar(), "██   ");
3064
3065        commit.score = 0.1;
3066        assert_eq!(commit.score_bar(), "█    ");
3067    }
3068
3069    #[test]
3070    fn test_commit_quality_score_quality_level() {
3071        let mut commit = CommitQualityScore {
3072            commit_hash: "abc".to_string(),
3073            commit_message: "test".to_string(),
3074            author: "author".to_string(),
3075            date: Local::now(),
3076            files_changed: 0,
3077            insertions: 0,
3078            deletions: 0,
3079            score: 0.0,
3080            message_score: 0.0,
3081            size_score: 0.0,
3082            test_score: 0.0,
3083            atomicity_score: 0.0,
3084        };
3085
3086        commit.score = 0.9;
3087        assert_eq!(commit.quality_level(), "Excellent");
3088
3089        commit.score = 0.7;
3090        assert_eq!(commit.quality_level(), "Good");
3091
3092        commit.score = 0.5;
3093        assert_eq!(commit.quality_level(), "Fair");
3094
3095        commit.score = 0.2;
3096        assert_eq!(commit.quality_level(), "Poor");
3097    }
3098
3099    #[test]
3100    fn test_quality_analysis_high_low_counts() {
3101        let events = vec![
3102            create_test_event_for_quality("high1", "feat: great feature", 30, 10),
3103            create_test_event_for_quality("high2", "fix: important fix", 20, 5),
3104            create_test_event_for_quality("low1", "x", 1000, 1000),
3105            create_test_event_for_quality("low2", ".", 2000, 500),
3106        ];
3107        let refs: Vec<&GitEvent> = events.iter().collect();
3108        let coupling = ChangeCouplingAnalysis::default();
3109
3110        let analysis = calculate_quality_scores(
3111            &refs,
3112            |hash| {
3113                if hash == "high1" || hash == "high2" {
3114                    Some(vec![
3115                        "src/feature.rs".to_string(),
3116                        "tests/feature_test.rs".to_string(),
3117                    ])
3118                } else {
3119                    Some((0..50).map(|i| format!("file{}.rs", i)).collect())
3120                }
3121            },
3122            &coupling,
3123        );
3124
3125        assert_eq!(analysis.total_commits, 4);
3126        // 高品質と低品質がカウントされている
3127        assert!(analysis.high_quality_count > 0 || analysis.low_quality_count > 0);
3128    }
3129
3130    // ===== Project Health テスト =====
3131
3132    #[test]
3133    fn test_project_health_default() {
3134        let health = ProjectHealth::default();
3135        assert_eq!(health.overall_score, 50);
3136        assert_eq!(health.level(), "Fair");
3137    }
3138
3139    #[test]
3140    fn test_project_health_level() {
3141        let health = ProjectHealth {
3142            overall_score: 90,
3143            ..Default::default()
3144        };
3145        assert_eq!(health.level(), "Excellent");
3146
3147        let health = ProjectHealth {
3148            overall_score: 75,
3149            ..Default::default()
3150        };
3151        assert_eq!(health.level(), "Good");
3152
3153        let health = ProjectHealth {
3154            overall_score: 50,
3155            ..Default::default()
3156        };
3157        assert_eq!(health.level(), "Fair");
3158
3159        let health = ProjectHealth {
3160            overall_score: 20,
3161            ..Default::default()
3162        };
3163        assert_eq!(health.level(), "Poor");
3164    }
3165
3166    #[test]
3167    fn test_project_health_score_bar() {
3168        let health = ProjectHealth {
3169            overall_score: 100,
3170            ..Default::default()
3171        };
3172        assert_eq!(health.score_bar(), "██████████");
3173
3174        let health = ProjectHealth {
3175            overall_score: 50,
3176            ..Default::default()
3177        };
3178        assert_eq!(health.score_bar(), "█████░░░░░");
3179
3180        let health = ProjectHealth {
3181            overall_score: 0,
3182            ..Default::default()
3183        };
3184        assert_eq!(health.score_bar(), "░░░░░░░░░░");
3185    }
3186
3187    #[test]
3188    fn test_alert_severity_ordering() {
3189        assert!(AlertSeverity::Critical > AlertSeverity::Warning);
3190        assert!(AlertSeverity::Warning > AlertSeverity::Info);
3191    }
3192
3193    #[test]
3194    fn test_calculate_project_health_empty() {
3195        let events: Vec<&GitEvent> = vec![];
3196        let health = calculate_project_health(&events, |_| None, None, None, None);
3197        assert_eq!(health.overall_score, 50);
3198        assert_eq!(health.total_commits, 0);
3199    }
3200
3201    #[test]
3202    fn test_calculate_project_health_with_events() {
3203        let events = vec![
3204            create_test_event_for_quality("hash1", "feat: add new feature", 50, 10),
3205            create_test_event_for_quality("hash2", "fix: bug fix", 20, 5),
3206            create_test_event_for_quality("hash3", "test: add tests", 30, 0),
3207        ];
3208        let refs: Vec<&GitEvent> = events.iter().collect();
3209
3210        let health = calculate_project_health(&refs, |_| None, None, None, None);
3211
3212        assert_eq!(health.total_commits, 3);
3213        assert!(health.overall_score > 0);
3214        // Conventional commits なので品質スコアが高いはず
3215        assert!(health.quality.score > 0.5);
3216    }
3217
3218    #[test]
3219    fn test_calculate_project_health_alerts() {
3220        // 1人の著者が全てのコミット → bus factor リスク
3221        let mut events = Vec::new();
3222        for i in 0..10 {
3223            let mut event = create_test_event("single_author", 10, 5);
3224            event.short_hash = format!("hash{}", i);
3225            events.push(event);
3226        }
3227        let refs: Vec<&GitEvent> = events.iter().collect();
3228
3229        let health = calculate_project_health(&refs, |_| None, None, None, None);
3230
3231        // 単一著者なのでbus factorアラートが発生するはず
3232        assert!(!health.alerts.is_empty());
3233        let has_bus_factor_alert = health
3234            .alerts
3235            .iter()
3236            .any(|a| a.message.contains("bus factor"));
3237        assert!(has_bus_factor_alert);
3238    }
3239}