gitstack 5.3.0

Git history viewer with insights - Author stats, file heatmap, code ownership
Documentation
//! Staged risk scoring
//!
//! Calculate risk score for staged changes based on file count, heatmap intensity,
//! ownership concentration, and change size.

use crate::git::FileStatus;
use crate::stats::{CodeOwnership, FileHeatmap};

/// Risk level classification
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum RiskLevel {
    #[default]
    Low,
    Medium,
    High,
}

impl RiskLevel {
    /// Label for display
    pub fn label(&self) -> &'static str {
        match self {
            Self::Low => "Low",
            Self::Medium => "Med",
            Self::High => "High",
        }
    }
}

/// Calculate risk score for staged changes
///
/// Returns (score: 0.0-1.0, risk_level)
///
/// Score components:
/// - File count weight: 0.3 (more files = higher risk)
/// - Heatmap intensity weight: 0.3 (frequently changed files = higher risk)
/// - Ownership concentration weight: 0.2 (changing others' code = higher risk)
/// - Change size weight: 0.2 (large changes = higher risk)
pub fn calculate_staged_risk(
    statuses: &[FileStatus],
    heatmap: &FileHeatmap,
    ownership: &CodeOwnership,
) -> (f64, RiskLevel) {
    if statuses.is_empty() {
        return (0.0, RiskLevel::Low);
    }

    let staged: Vec<&FileStatus> = statuses.iter().filter(|s| s.kind.is_staged()).collect();
    if staged.is_empty() {
        return (0.0, RiskLevel::Low);
    }

    // 1. File count score (0.0-1.0)
    let file_count_score = (staged.len() as f64 / 20.0).min(1.0);

    // 2. Heatmap intensity score (0.0-1.0)
    let heatmap_score = if heatmap.files.is_empty() {
        0.0
    } else {
        let max_changes = heatmap
            .files
            .iter()
            .map(|e| e.change_count)
            .max()
            .unwrap_or(1) as f64;

        let avg_heat: f64 = staged
            .iter()
            .map(|s| {
                heatmap
                    .files
                    .iter()
                    .find(|e| e.path == s.path)
                    .map(|e| e.change_count as f64 / max_changes)
                    .unwrap_or(0.0)
            })
            .sum::<f64>()
            / staged.len() as f64;
        avg_heat
    };

    // 3. Ownership concentration score (0.0-1.0)
    // Higher score when modifying files owned by different people (multi-owner files)
    let ownership_score = if ownership.entries.is_empty() {
        0.0
    } else {
        // Use ownership ratio: files where primary_author has low ownership ratio = risky
        let risky_files = staged
            .iter()
            .filter(|s| {
                ownership.entries.iter().any(|e| {
                    e.path == s.path
                        && e.total_commits > 0
                        && (e.primary_commits as f64 / e.total_commits as f64) < 0.5
                })
            })
            .count();
        (risky_files as f64 / staged.len() as f64).min(1.0)
    };

    // 4. Change size score (based on file count as proxy)
    let change_size_score = (staged.len() as f64 / 10.0).min(1.0);

    // Weighted sum
    let score = file_count_score * 0.3
        + heatmap_score * 0.3
        + ownership_score * 0.2
        + change_size_score * 0.2;

    let level = if score < 0.3 {
        RiskLevel::Low
    } else if score < 0.7 {
        RiskLevel::Medium
    } else {
        RiskLevel::High
    };

    (score, level)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::git::FileStatusKind;
    use crate::stats::{AggregationLevel, CodeOwnershipEntry, FileHeatmapEntry};

    fn empty_heatmap() -> FileHeatmap {
        FileHeatmap {
            files: vec![],
            total_files: 0,
            aggregation_level: AggregationLevel::Files,
        }
    }

    fn empty_ownership() -> CodeOwnership {
        CodeOwnership {
            entries: vec![],
            total_files: 0,
        }
    }

    fn make_staged(path: &str) -> FileStatus {
        FileStatus {
            path: path.to_string(),
            kind: FileStatusKind::StagedNew,
        }
    }

    fn make_unstaged(path: &str) -> FileStatus {
        FileStatus {
            path: path.to_string(),
            kind: FileStatusKind::Modified,
        }
    }

    #[test]
    fn test_risk_level_label() {
        assert_eq!(RiskLevel::Low.label(), "Low");
        assert_eq!(RiskLevel::Medium.label(), "Med");
        assert_eq!(RiskLevel::High.label(), "High");
    }

    #[test]
    fn test_risk_level_default() {
        assert_eq!(RiskLevel::default(), RiskLevel::Low);
    }

    #[test]
    fn test_calculate_staged_risk_empty() {
        let (score, level) = calculate_staged_risk(&[], &empty_heatmap(), &empty_ownership());
        assert_eq!(score, 0.0);
        assert_eq!(level, RiskLevel::Low);
    }

    #[test]
    fn test_calculate_staged_risk_no_staged_files() {
        let statuses = vec![make_unstaged("src/main.rs")];
        let (score, level) = calculate_staged_risk(&statuses, &empty_heatmap(), &empty_ownership());
        assert_eq!(score, 0.0);
        assert_eq!(level, RiskLevel::Low);
    }

    #[test]
    fn test_calculate_staged_risk_single_file_low() {
        let statuses = vec![make_staged("src/main.rs")];
        let (score, level) = calculate_staged_risk(&statuses, &empty_heatmap(), &empty_ownership());
        assert!(score < 0.3);
        assert_eq!(level, RiskLevel::Low);
    }

    #[test]
    fn test_calculate_staged_risk_many_files_higher() {
        let statuses: Vec<FileStatus> = (0..15)
            .map(|i| make_staged(&format!("src/file{}.rs", i)))
            .collect();
        let (score, _level) =
            calculate_staged_risk(&statuses, &empty_heatmap(), &empty_ownership());
        // 15 files → file_count_score = 15/20 = 0.75, change_size_score = 1.0
        assert!(score > 0.3);
    }

    #[test]
    fn test_calculate_staged_risk_hot_files_increase_risk() {
        let statuses = vec![make_staged("src/hot.rs")];
        let heatmap = FileHeatmap {
            files: vec![FileHeatmapEntry {
                path: "src/hot.rs".to_string(),
                change_count: 100,
                max_changes: 100,
            }],
            total_files: 1,
            aggregation_level: AggregationLevel::Files,
        };
        let (score_with_heat, _) = calculate_staged_risk(&statuses, &heatmap, &empty_ownership());
        let (score_without_heat, _) =
            calculate_staged_risk(&statuses, &empty_heatmap(), &empty_ownership());
        assert!(score_with_heat > score_without_heat);
    }

    #[test]
    fn test_calculate_staged_risk_multi_owner_increases_risk() {
        let statuses = vec![make_staged("src/shared.rs")];
        let ownership = CodeOwnership {
            entries: vec![CodeOwnershipEntry {
                path: "src/shared.rs".to_string(),
                primary_author: "alice".to_string(),
                primary_commits: 2,
                total_commits: 10, // Low concentration → risky
                depth: 0,
                is_directory: false,
            }],
            total_files: 1,
        };
        let (score_with_own, _) = calculate_staged_risk(&statuses, &empty_heatmap(), &ownership);
        let (score_without_own, _) =
            calculate_staged_risk(&statuses, &empty_heatmap(), &empty_ownership());
        assert!(score_with_own > score_without_own);
    }

    #[test]
    fn test_calculate_staged_risk_high_threshold() {
        // 25 staged files with hot heatmap and multi-owner → should be High
        let statuses: Vec<FileStatus> = (0..25)
            .map(|i| make_staged(&format!("src/file{}.rs", i)))
            .collect();
        let heatmap = FileHeatmap {
            files: (0..25)
                .map(|i| FileHeatmapEntry {
                    path: format!("src/file{}.rs", i),
                    change_count: 50,
                    max_changes: 50,
                })
                .collect(),
            total_files: 25,
            aggregation_level: AggregationLevel::Files,
        };
        let ownership = CodeOwnership {
            entries: (0..25)
                .map(|i| CodeOwnershipEntry {
                    path: format!("src/file{}.rs", i),
                    primary_author: "bob".to_string(),
                    primary_commits: 1,
                    total_commits: 10,
                    depth: 0,
                    is_directory: false,
                })
                .collect(),
            total_files: 25,
        };
        let (score, level) = calculate_staged_risk(&statuses, &heatmap, &ownership);
        assert!(score >= 0.7);
        assert_eq!(level, RiskLevel::High);
    }

    #[test]
    fn test_risk_level_eq_and_hash() {
        use std::collections::HashSet;
        let mut set = HashSet::new();
        set.insert(RiskLevel::Low);
        set.insert(RiskLevel::Medium);
        set.insert(RiskLevel::High);
        assert_eq!(set.len(), 3);
    }

    #[test]
    fn test_risk_level_clone_copy() {
        let level = RiskLevel::Medium;
        let cloned = level;
        assert_eq!(level, cloned);
    }
}