use crate::git::FileStatus;
use crate::stats::{CodeOwnership, FileHeatmap};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum RiskLevel {
#[default]
Low,
Medium,
High,
}
impl RiskLevel {
pub fn label(&self) -> &'static str {
match self {
Self::Low => "Low",
Self::Medium => "Med",
Self::High => "High",
}
}
}
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);
}
let file_count_score = (staged.len() as f64 / 20.0).min(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
};
let ownership_score = if ownership.entries.is_empty() {
0.0
} else {
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)
};
let change_size_score = (staged.len() as f64 / 10.0).min(1.0);
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());
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, 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() {
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);
}
}