use chrono::{DateTime, Local};
use std::collections::HashMap;
use crate::event::GitEvent;
use super::FileHeatmap;
const SCORE_EXCELLENT: f64 = 0.8;
const SCORE_GOOD: f64 = 0.6;
const SCORE_FAIR: f64 = 0.4;
const SCORE_POOR: f64 = 0.2;
const IMPACT_HIGH: f64 = 0.7;
const IMPACT_MEDIUM: f64 = 0.4;
const MSG_CONVENTIONAL_WEIGHT: f64 = 0.4;
const MSG_LENGTH_WEIGHT: f64 = 0.3;
const MSG_LENGTH_ACCEPTABLE_WEIGHT: f64 = 0.15;
const MSG_MEANINGFUL_WEIGHT: f64 = 0.3;
const MSG_OPTIMAL_MIN: usize = 10;
const MSG_OPTIMAL_MAX: usize = 72;
const MSG_ACCEPTABLE_MIN: usize = 5;
const MSG_ACCEPTABLE_MAX: usize = 100;
const MSG_MIN_MEANINGFUL_LEN: usize = 3;
const IDEAL_FILES_MAX: usize = 5;
const ACCEPTABLE_FILES_MAX: usize = 10;
const LARGE_FILES_MAX: usize = 20;
const IDEAL_CHANGES_MIN: usize = 10;
const IDEAL_CHANGES_MAX: usize = 200;
const ACCEPTABLE_CHANGES_MAX: usize = 500;
const LARGE_CHANGES_MAX: usize = 1000;
const IMPACT_FILE_NORMALIZATION: f64 = 50.0;
const IMPACT_CHANGE_NORMALIZATION: f64 = 500.0;
const COUPLING_HIGH: f64 = 0.7;
const COUPLING_BAR_LEVELS: f64 = 10.0;
#[derive(Debug, Clone)]
pub struct CommitQualityScore {
pub commit_hash: String,
pub commit_message: String,
pub author: String,
pub date: DateTime<Local>,
pub files_changed: usize,
pub insertions: usize,
pub deletions: usize,
pub score: f64,
pub message_score: f64,
pub size_score: f64,
pub test_score: f64,
pub atomicity_score: f64,
}
impl CommitQualityScore {
pub fn score_color(&self) -> &'static str {
if self.score >= SCORE_GOOD {
"green" } else if self.score >= SCORE_FAIR {
"yellow" } else {
"red" }
}
pub fn score_bar(&self) -> &'static str {
if self.score >= SCORE_EXCELLENT {
"█████"
} else if self.score >= SCORE_GOOD {
"████ "
} else if self.score >= SCORE_FAIR {
"███ "
} else if self.score >= SCORE_POOR {
"██ "
} else {
"█ "
}
}
pub fn quality_level(&self) -> &'static str {
if self.score >= SCORE_EXCELLENT {
"Excellent"
} else if self.score >= SCORE_GOOD {
"Good"
} else if self.score >= SCORE_FAIR {
"Fair"
} else {
"Poor"
}
}
}
#[derive(Debug, Clone, Default)]
pub struct CommitQualityAnalysis {
pub commits: Vec<CommitQualityScore>,
pub total_commits: usize,
pub avg_score: f64,
pub high_quality_count: usize,
pub low_quality_count: usize,
}
impl CommitQualityAnalysis {
pub fn commit_count(&self) -> usize {
self.commits.len()
}
}
fn calculate_message_quality(message: &str) -> f64 {
let mut score = 0.0;
let conventional_prefixes = [
"feat:",
"fix:",
"docs:",
"style:",
"refactor:",
"test:",
"chore:",
"perf:",
"ci:",
"build:",
"revert:",
];
let has_conventional_prefix = conventional_prefixes
.iter()
.any(|prefix| message.to_lowercase().starts_with(prefix));
if has_conventional_prefix {
score += MSG_CONVENTIONAL_WEIGHT;
}
let len = message.len();
if (MSG_OPTIMAL_MIN..=MSG_OPTIMAL_MAX).contains(&len) {
score += MSG_LENGTH_WEIGHT;
} else if (MSG_ACCEPTABLE_MIN..=MSG_ACCEPTABLE_MAX).contains(&len) {
score += MSG_LENGTH_ACCEPTABLE_WEIGHT;
}
let meaningless_patterns = ["update", "fix", "wip", "changes", "commit", "test", "."];
let trimmed = message.trim().to_lowercase();
let is_meaningful = !meaningless_patterns.contains(&trimmed.as_str())
&& message.trim().len() > MSG_MIN_MEANINGFUL_LEN
&& !message.trim().chars().all(|c| !c.is_alphabetic());
if is_meaningful {
score += MSG_MEANINGFUL_WEIGHT;
}
score
}
fn calculate_size_appropriateness(
files_changed: usize,
insertions: usize,
deletions: usize,
) -> f64 {
let mut score = 0.0;
let file_score = if files_changed == 0 {
0.0
} else if files_changed <= IDEAL_FILES_MAX {
0.5
} else if files_changed <= ACCEPTABLE_FILES_MAX {
0.3
} else if files_changed <= LARGE_FILES_MAX {
0.1
} else {
0.0
};
score += file_score;
let total_changes = insertions + deletions;
let change_score = if total_changes == 0 {
0.0
} else if (IDEAL_CHANGES_MIN..=IDEAL_CHANGES_MAX).contains(&total_changes) {
0.5
} else if (1..=ACCEPTABLE_CHANGES_MAX).contains(&total_changes) {
0.3
} else if total_changes <= LARGE_CHANGES_MAX {
0.1
} else {
0.0
};
score += change_score;
score
}
fn calculate_test_presence(files: &[String]) -> f64 {
if files.is_empty() {
return 0.0;
}
let has_source = files.iter().any(|f| {
let f_lower = f.to_lowercase();
!f_lower.contains("test")
&& !f_lower.contains("spec")
&& (f_lower.ends_with(".rs")
|| f_lower.ends_with(".ts")
|| f_lower.ends_with(".js")
|| f_lower.ends_with(".py")
|| f_lower.ends_with(".go")
|| f_lower.ends_with(".java")
|| f_lower.ends_with(".rb")
|| f_lower.ends_with(".c")
|| f_lower.ends_with(".cpp")
|| f_lower.ends_with(".h"))
});
let has_test = files.iter().any(|f| {
let f_lower = f.to_lowercase();
f_lower.contains("test")
|| f_lower.contains("spec")
|| f_lower.contains("_test.")
|| f_lower.contains(".test.")
});
if has_source && has_test {
1.0 } else if has_test {
0.8 } else if has_source {
0.3 } else {
0.5 }
}
fn calculate_atomicity(files: &[String], coupling: &ChangeCouplingAnalysis) -> f64 {
if files.is_empty() {
return 0.0;
}
if files.len() == 1 {
return 1.0; }
let dirs: std::collections::HashSet<&str> =
files.iter().filter_map(|f| f.rsplit('/').nth(1)).collect();
let dir_score = if dirs.len() == 1 {
0.5 } else if dirs.len() <= 2 {
0.35
} else if dirs.len() <= 3 {
0.2
} else {
0.1
};
let mut coupling_sum = 0.0;
let mut coupling_count = 0;
for file in files {
for other_file in files {
if file != other_file {
if let Some(c) = coupling
.couplings
.iter()
.find(|c| &c.file == file && &c.coupled_file == other_file)
{
coupling_sum += c.coupling_percent;
coupling_count += 1;
}
}
}
}
let coupling_score = if coupling_count > 0 {
(coupling_sum / coupling_count as f64) * 0.5
} else {
0.25 };
dir_score + coupling_score
}
pub fn calculate_quality_scores(
events: &[&GitEvent],
get_files: impl Fn(&str) -> Option<Vec<String>>,
coupling: &ChangeCouplingAnalysis,
) -> CommitQualityAnalysis {
let mut commits = Vec::new();
let mut total_score = 0.0;
let mut high_quality_count = 0;
let mut low_quality_count = 0;
for event in events {
let files = get_files(&event.short_hash).unwrap_or_default();
let message_score = calculate_message_quality(&event.message) * 0.30;
let size_score =
calculate_size_appropriateness(files.len(), event.files_added, event.files_deleted)
* 0.25;
let test_score = calculate_test_presence(&files) * 0.25;
let atomicity_score = calculate_atomicity(&files, coupling) * 0.20;
let score = message_score + size_score + test_score + atomicity_score;
commits.push(CommitQualityScore {
commit_hash: event.short_hash.clone(),
commit_message: event.message.clone(),
author: event.author.clone(),
date: event.timestamp,
files_changed: files.len(),
insertions: event.files_added,
deletions: event.files_deleted,
score,
message_score,
size_score,
test_score,
atomicity_score,
});
total_score += score;
if score >= SCORE_GOOD {
high_quality_count += 1;
}
if score < SCORE_FAIR {
low_quality_count += 1;
}
}
commits.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
});
let total_commits = commits.len();
let avg_score = if total_commits > 0 {
total_score / total_commits as f64
} else {
0.0
};
CommitQualityAnalysis {
commits,
total_commits,
avg_score,
high_quality_count,
low_quality_count,
}
}
#[derive(Debug, Clone)]
pub struct CommitImpactScore {
pub commit_hash: String,
pub commit_message: String,
pub author: String,
pub date: DateTime<Local>,
pub files_changed: usize,
pub insertions: usize,
pub deletions: usize,
pub score: f64,
pub file_score: f64,
pub change_score: f64,
pub heat_score: f64,
}
impl CommitImpactScore {
pub fn score_color(&self) -> &'static str {
if self.score >= IMPACT_HIGH {
"red" } else if self.score >= IMPACT_MEDIUM {
"yellow" } else {
"green" }
}
pub fn score_bar(&self) -> &'static str {
if self.score >= SCORE_EXCELLENT {
"█████"
} else if self.score >= SCORE_GOOD {
"████ "
} else if self.score >= SCORE_FAIR {
"███ "
} else if self.score >= SCORE_POOR {
"██ "
} else {
"█ "
}
}
}
#[derive(Debug, Clone, Default)]
pub struct CommitImpactAnalysis {
pub commits: Vec<CommitImpactScore>,
pub total_commits: usize,
pub avg_score: f64,
pub max_score: f64,
pub high_impact_count: usize,
}
impl CommitImpactAnalysis {
pub fn commit_count(&self) -> usize {
self.commits.len()
}
}
pub fn calculate_impact_scores(
events: &[&GitEvent],
get_files: impl Fn(&str) -> Option<Vec<String>>,
file_heatmap: &FileHeatmap,
) -> CommitImpactAnalysis {
let mut commits = Vec::new();
let mut total_score = 0.0;
let mut max_score = 0.0f64;
let mut high_impact_count = 0;
let heat_map: HashMap<&str, f64> = file_heatmap
.files
.iter()
.map(|f| (f.path.as_str(), f.heat_level()))
.collect();
for event in events {
let files = get_files(&event.short_hash).unwrap_or_default();
let files_changed = files.len();
let total_changes = event.files_added + event.files_deleted;
let file_score = (files_changed as f64 / IMPACT_FILE_NORMALIZATION).min(1.0) * 0.4;
let change_score = (total_changes as f64 / IMPACT_CHANGE_NORMALIZATION).min(1.0) * 0.4;
let avg_file_heat = if files.is_empty() {
0.0
} else {
let total_heat: f64 = files
.iter()
.map(|f| heat_map.get(f.as_str()).copied().unwrap_or(0.0))
.sum();
total_heat / files.len() as f64
};
let heat_score = avg_file_heat * 0.2;
let score = file_score + change_score + heat_score;
commits.push(CommitImpactScore {
commit_hash: event.short_hash.clone(),
commit_message: event.message.clone(),
author: event.author.clone(),
date: event.timestamp,
files_changed,
insertions: event.files_added,
deletions: event.files_deleted,
score,
file_score,
change_score,
heat_score,
});
total_score += score;
max_score = max_score.max(score);
if score >= IMPACT_HIGH {
high_impact_count += 1;
}
}
commits.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
});
let total_commits = commits.len();
let avg_score = if total_commits > 0 {
total_score / total_commits as f64
} else {
0.0
};
CommitImpactAnalysis {
commits,
total_commits,
avg_score,
max_score,
high_impact_count,
}
}
#[derive(Debug, Clone)]
pub struct FileCoupling {
pub file: String,
pub coupled_file: String,
pub co_change_count: usize,
pub file_change_count: usize,
pub coupling_percent: f64,
}
impl FileCoupling {
pub fn coupling_bar(&self) -> String {
let filled = (self.coupling_percent.clamp(0.0, 1.0) * COUPLING_BAR_LEVELS).round() as usize;
let empty = (COUPLING_BAR_LEVELS as usize).saturating_sub(filled);
format!("[{}{}]", "█".repeat(filled), "░".repeat(empty))
}
}
#[derive(Debug, Clone, Default)]
pub struct ChangeCouplingAnalysis {
pub couplings: Vec<FileCoupling>,
pub high_coupling_count: usize,
pub total_files_analyzed: usize,
}
impl ChangeCouplingAnalysis {
pub fn coupling_count(&self) -> usize {
self.couplings.len()
}
pub fn grouped_by_file(&self) -> Vec<(&str, Vec<&FileCoupling>)> {
use std::collections::HashMap;
let mut groups: HashMap<&str, Vec<&FileCoupling>> = HashMap::new();
for coupling in &self.couplings {
groups.entry(&coupling.file).or_default().push(coupling);
}
let mut result: Vec<_> = groups.into_iter().collect();
result.sort_by(|a, b| {
let count_a = a.1.first().map(|c| c.file_change_count).unwrap_or(0);
let count_b = b.1.first().map(|c| c.file_change_count).unwrap_or(0);
count_b.cmp(&count_a)
});
result
}
}
pub fn calculate_change_coupling(
events: &[&GitEvent],
get_files: impl Fn(&str) -> Option<Vec<String>>,
min_commits: usize,
min_coupling: f64,
) -> ChangeCouplingAnalysis {
use std::collections::{HashMap, HashSet};
let mut file_change_counts: HashMap<String, usize> = HashMap::new();
let mut commit_files: Vec<HashSet<String>> = Vec::new();
for event in events {
if let Some(files) = get_files(&event.short_hash) {
let file_set: HashSet<String> = files.iter().cloned().collect();
for file in &file_set {
*file_change_counts.entry(file.clone()).or_insert(0) += 1;
}
commit_files.push(file_set);
}
}
let mut pair_counts: HashMap<(String, String), usize> = HashMap::new();
for files in &commit_files {
let relevant_files: Vec<_> = files
.iter()
.filter(|f| *file_change_counts.get(f.as_str()).unwrap_or(&0) >= min_commits)
.collect();
for i in 0..relevant_files.len() {
for j in (i + 1)..relevant_files.len() {
let (a, b) = (relevant_files[i], relevant_files[j]);
*pair_counts.entry((a.clone(), b.clone())).or_insert(0) += 1;
*pair_counts.entry((b.clone(), a.clone())).or_insert(0) += 1;
}
}
}
let mut couplings: Vec<FileCoupling> = Vec::new();
let mut high_coupling_count = 0;
let mut analyzed_files: HashSet<String> = HashSet::new();
for ((file, coupled_file), co_change_count) in &pair_counts {
let file_change_count = *file_change_counts.get(file).unwrap_or(&0);
if file_change_count < min_commits {
continue;
}
let coupling_percent = *co_change_count as f64 / file_change_count as f64;
if coupling_percent < min_coupling {
continue;
}
analyzed_files.insert(file.clone());
if coupling_percent >= COUPLING_HIGH {
high_coupling_count += 1;
}
couplings.push(FileCoupling {
file: file.clone(),
coupled_file: coupled_file.clone(),
co_change_count: *co_change_count,
file_change_count,
coupling_percent,
});
}
couplings.sort_by(|a, b| {
b.coupling_percent
.partial_cmp(&a.coupling_percent)
.unwrap_or(std::cmp::Ordering::Equal)
});
high_coupling_count /= 2;
ChangeCouplingAnalysis {
couplings,
high_coupling_count,
total_files_analyzed: analyzed_files.len(),
}
}
#[cfg(test)]
#[allow(clippy::useless_vec)]
mod tests {
use super::*;
use chrono::Local;
fn create_test_event_with_hash(hash: &str) -> GitEvent {
GitEvent::commit(
hash.to_string(),
"test commit".to_string(),
"author".to_string(),
Local::now(),
10,
5,
)
}
fn create_test_event_with_hash_changes(
hash: &str,
author: &str,
insertions: usize,
deletions: usize,
) -> GitEvent {
GitEvent::commit(
hash.to_string(),
"test commit".to_string(),
author.to_string(),
Local::now(),
insertions,
deletions,
)
}
fn create_test_event_for_quality(
hash: &str,
message: &str,
insertions: usize,
deletions: usize,
) -> GitEvent {
GitEvent::commit(
hash.to_string(),
message.to_string(),
"author".to_string(),
Local::now(),
insertions,
deletions,
)
}
#[test]
fn test_calculate_impact_scores_empty() {
let events: Vec<&GitEvent> = vec![];
let heatmap = FileHeatmap::default();
let analysis = calculate_impact_scores(&events, |_| None, &heatmap);
assert_eq!(analysis.total_commits, 0);
assert_eq!(analysis.commits.len(), 0);
assert_eq!(analysis.avg_score, 0.0);
}
#[test]
fn test_calculate_impact_scores_single_commit() {
let events = vec![create_test_event_with_hash_changes(
"abc1234", "Alice", 100, 50,
)];
let refs: Vec<&GitEvent> = events.iter().collect();
let heatmap = FileHeatmap::default();
let analysis =
calculate_impact_scores(&refs, |_| Some(vec!["src/main.rs".to_string()]), &heatmap);
assert_eq!(analysis.total_commits, 1);
assert_eq!(analysis.commits.len(), 1);
let commit = &analysis.commits[0];
assert_eq!(commit.commit_hash, "abc1234");
assert_eq!(commit.files_changed, 1);
assert_eq!(commit.insertions, 100);
assert_eq!(commit.deletions, 50);
}
#[test]
fn test_calculate_impact_scores_file_score() {
let events = vec![create_test_event_with_hash_changes(
"abc1234", "Alice", 10, 5,
)];
let refs: Vec<&GitEvent> = events.iter().collect();
let heatmap = FileHeatmap::default();
let files: Vec<String> = (0..50).map(|i| format!("file{}.rs", i)).collect();
let analysis = calculate_impact_scores(&refs, |_| Some(files.clone()), &heatmap);
let commit = &analysis.commits[0];
assert!((commit.file_score - 0.4).abs() < 0.01);
}
#[test]
fn test_calculate_impact_scores_change_score() {
let events = vec![create_test_event_with_hash_changes(
"abc1234", "Alice", 250, 250,
)];
let refs: Vec<&GitEvent> = events.iter().collect();
let heatmap = FileHeatmap::default();
let analysis =
calculate_impact_scores(&refs, |_| Some(vec!["src/main.rs".to_string()]), &heatmap);
let commit = &analysis.commits[0];
assert!((commit.change_score - 0.4).abs() < 0.01);
}
#[test]
fn test_calculate_impact_scores_sorted_by_score_desc() {
let events = vec![
create_test_event_with_hash_changes("low", "Alice", 10, 5), create_test_event_with_hash_changes("high", "Bob", 400, 100), create_test_event_with_hash_changes("medium", "Carol", 100, 50), ];
let refs: Vec<&GitEvent> = events.iter().collect();
let heatmap = FileHeatmap::default();
let analysis =
calculate_impact_scores(&refs, |_| Some(vec!["src/main.rs".to_string()]), &heatmap);
assert_eq!(analysis.commits.len(), 3);
assert_eq!(analysis.commits[0].commit_hash, "high");
assert_eq!(analysis.commits[1].commit_hash, "medium");
assert_eq!(analysis.commits[2].commit_hash, "low");
}
#[test]
fn test_commit_impact_score_color_high() {
let commit = CommitImpactScore {
commit_hash: "abc".to_string(),
commit_message: "test".to_string(),
author: "Alice".to_string(),
date: Local::now(),
files_changed: 0,
insertions: 0,
deletions: 0,
score: 0.8,
file_score: 0.0,
change_score: 0.0,
heat_score: 0.0,
};
assert_eq!(commit.score_color(), "red");
}
#[test]
fn test_commit_impact_score_color_medium() {
let commit = CommitImpactScore {
commit_hash: "abc".to_string(),
commit_message: "test".to_string(),
author: "Alice".to_string(),
date: Local::now(),
files_changed: 0,
insertions: 0,
deletions: 0,
score: 0.5,
file_score: 0.0,
change_score: 0.0,
heat_score: 0.0,
};
assert_eq!(commit.score_color(), "yellow");
}
#[test]
fn test_commit_impact_score_color_low() {
let commit = CommitImpactScore {
commit_hash: "abc".to_string(),
commit_message: "test".to_string(),
author: "Alice".to_string(),
date: Local::now(),
files_changed: 0,
insertions: 0,
deletions: 0,
score: 0.2,
file_score: 0.0,
change_score: 0.0,
heat_score: 0.0,
};
assert_eq!(commit.score_color(), "green");
}
#[test]
fn test_commit_impact_score_bar() {
let mut commit = CommitImpactScore {
commit_hash: "abc".to_string(),
commit_message: "test".to_string(),
author: "Alice".to_string(),
date: Local::now(),
files_changed: 0,
insertions: 0,
deletions: 0,
score: 0.0,
file_score: 0.0,
change_score: 0.0,
heat_score: 0.0,
};
commit.score = 0.9;
assert_eq!(commit.score_bar(), "█████");
commit.score = 0.7;
assert_eq!(commit.score_bar(), "████ ");
commit.score = 0.5;
assert_eq!(commit.score_bar(), "███ ");
commit.score = 0.3;
assert_eq!(commit.score_bar(), "██ ");
commit.score = 0.1;
assert_eq!(commit.score_bar(), "█ ");
}
#[test]
fn test_calculate_impact_scores_high_impact_count() {
let events = vec![
create_test_event_with_hash_changes("a", "Alice", 400, 100), create_test_event_with_hash_changes("b", "Bob", 300, 100), create_test_event_with_hash_changes("c", "Carol", 10, 5), ];
let refs: Vec<&GitEvent> = events.iter().collect();
let heatmap = FileHeatmap::default();
let analysis = calculate_impact_scores(
&refs,
|hash| {
if hash == "a" {
Some((0..50).map(|i| format!("file{}.rs", i)).collect())
} else {
Some(vec!["file.rs".to_string()])
}
},
&heatmap,
);
assert!(analysis.high_impact_count >= 1);
}
#[test]
fn test_calculate_change_coupling_empty() {
let events: Vec<&GitEvent> = vec![];
let analysis = calculate_change_coupling(&events, |_| None, 5, 0.3);
assert_eq!(analysis.coupling_count(), 0);
assert_eq!(analysis.high_coupling_count, 0);
assert_eq!(analysis.total_files_analyzed, 0);
}
#[test]
fn test_calculate_change_coupling_single_file() {
let events = vec![
create_test_event_with_hash("commit1"),
create_test_event_with_hash("commit2"),
create_test_event_with_hash("commit3"),
create_test_event_with_hash("commit4"),
create_test_event_with_hash("commit5"),
];
let refs: Vec<&GitEvent> = events.iter().collect();
let analysis =
calculate_change_coupling(&refs, |_| Some(vec!["src/main.rs".to_string()]), 1, 0.0);
assert_eq!(analysis.coupling_count(), 0);
}
#[test]
fn test_calculate_change_coupling_pair() {
let events = vec![
create_test_event_with_hash("commit1"),
create_test_event_with_hash("commit2"),
create_test_event_with_hash("commit3"),
create_test_event_with_hash("commit4"),
create_test_event_with_hash("commit5"),
];
let refs: Vec<&GitEvent> = events.iter().collect();
let analysis = calculate_change_coupling(
&refs,
|_| Some(vec!["src/app.rs".to_string(), "src/ui.rs".to_string()]),
5,
0.3,
);
assert_eq!(analysis.coupling_count(), 2);
for coupling in &analysis.couplings {
assert!((coupling.coupling_percent - 1.0).abs() < 0.01);
assert_eq!(coupling.co_change_count, 5);
assert_eq!(coupling.file_change_count, 5);
}
}
#[test]
fn test_calculate_change_coupling_partial() {
let events = vec![
create_test_event_with_hash("commit1"),
create_test_event_with_hash("commit2"),
create_test_event_with_hash("commit3"),
create_test_event_with_hash("commit4"),
create_test_event_with_hash("commit5"),
create_test_event_with_hash("commit6"),
create_test_event_with_hash("commit7"),
create_test_event_with_hash("commit8"),
create_test_event_with_hash("commit9"),
create_test_event_with_hash("commit10"),
];
let refs: Vec<&GitEvent> = events.iter().collect();
let analysis = calculate_change_coupling(
&refs,
|hash| {
if hash == "commit9" || hash == "commit10" {
Some(vec!["src/app.rs".to_string()])
} else {
Some(vec!["src/app.rs".to_string(), "src/ui.rs".to_string()])
}
},
5,
0.3,
);
let app_to_ui = analysis
.couplings
.iter()
.find(|c| c.file == "src/app.rs" && c.coupled_file == "src/ui.rs");
assert!(app_to_ui.is_some());
let coupling = app_to_ui.unwrap();
assert!((coupling.coupling_percent - 0.8).abs() < 0.01);
let ui_to_app = analysis
.couplings
.iter()
.find(|c| c.file == "src/ui.rs" && c.coupled_file == "src/app.rs");
assert!(ui_to_app.is_some());
let coupling = ui_to_app.unwrap();
assert!((coupling.coupling_percent - 1.0).abs() < 0.01);
}
#[test]
fn test_calculate_change_coupling_min_commits_filter() {
let events = vec![
create_test_event_with_hash("commit1"),
create_test_event_with_hash("commit2"),
create_test_event_with_hash("commit3"),
];
let refs: Vec<&GitEvent> = events.iter().collect();
let analysis = calculate_change_coupling(
&refs,
|_| Some(vec!["src/app.rs".to_string(), "src/ui.rs".to_string()]),
5,
0.3,
);
assert_eq!(analysis.coupling_count(), 0);
}
#[test]
fn test_calculate_change_coupling_min_coupling_filter() {
let events = vec![
create_test_event_with_hash("commit1"),
create_test_event_with_hash("commit2"),
create_test_event_with_hash("commit3"),
create_test_event_with_hash("commit4"),
create_test_event_with_hash("commit5"),
];
let refs: Vec<&GitEvent> = events.iter().collect();
let analysis = calculate_change_coupling(
&refs,
|hash| {
if hash == "commit1" {
Some(vec!["src/app.rs".to_string(), "src/ui.rs".to_string()])
} else {
Some(vec!["src/app.rs".to_string()])
}
},
1,
0.3, );
let app_to_ui = analysis
.couplings
.iter()
.find(|c| c.file == "src/app.rs" && c.coupled_file == "src/ui.rs");
assert!(app_to_ui.is_none());
}
#[test]
fn test_calculate_change_coupling_high_coupling_count() {
let events = vec![
create_test_event_with_hash("commit1"),
create_test_event_with_hash("commit2"),
create_test_event_with_hash("commit3"),
create_test_event_with_hash("commit4"),
create_test_event_with_hash("commit5"),
];
let refs: Vec<&GitEvent> = events.iter().collect();
let analysis = calculate_change_coupling(
&refs,
|_| Some(vec!["src/app.rs".to_string(), "src/ui.rs".to_string()]),
5,
0.3,
);
assert_eq!(analysis.high_coupling_count, 1);
}
#[test]
fn test_file_coupling_bar() {
let coupling = FileCoupling {
file: "src/app.rs".to_string(),
coupled_file: "src/ui.rs".to_string(),
co_change_count: 8,
file_change_count: 10,
coupling_percent: 0.8,
};
assert_eq!(coupling.coupling_bar(), "[████████░░]");
}
#[test]
fn test_change_coupling_grouped_by_file() {
let analysis = ChangeCouplingAnalysis {
couplings: vec![
FileCoupling {
file: "src/app.rs".to_string(),
coupled_file: "src/ui.rs".to_string(),
co_change_count: 8,
file_change_count: 10,
coupling_percent: 0.8,
},
FileCoupling {
file: "src/app.rs".to_string(),
coupled_file: "src/main.rs".to_string(),
co_change_count: 5,
file_change_count: 10,
coupling_percent: 0.5,
},
FileCoupling {
file: "src/git.rs".to_string(),
coupled_file: "src/filter.rs".to_string(),
co_change_count: 3,
file_change_count: 5,
coupling_percent: 0.6,
},
],
high_coupling_count: 1,
total_files_analyzed: 5,
};
let grouped = analysis.grouped_by_file();
assert_eq!(grouped.len(), 2);
assert_eq!(grouped[0].0, "src/app.rs");
assert_eq!(grouped[0].1.len(), 2);
assert_eq!(grouped[1].0, "src/git.rs");
assert_eq!(grouped[1].1.len(), 1);
}
#[test]
fn test_calculate_message_quality_conventional() {
let score = calculate_message_quality("feat: add new feature for user authentication");
assert!(score >= 0.7, "Expected >= 0.7, got {}", score);
}
#[test]
fn test_calculate_message_quality_conventional_fix() {
let score = calculate_message_quality("fix: resolve memory leak in connection pool");
assert!(score >= 0.7, "Expected >= 0.7, got {}", score);
}
#[test]
fn test_calculate_message_quality_non_conventional() {
let score = calculate_message_quality("Add user authentication feature");
assert!(
(0.3..0.7).contains(&score),
"Expected 0.3-0.7, got {}",
score
);
}
#[test]
fn test_calculate_message_quality_short() {
let score = calculate_message_quality("fix");
assert!(score < 0.3, "Expected < 0.3, got {}", score);
}
#[test]
fn test_calculate_message_quality_empty() {
let score = calculate_message_quality("");
assert_eq!(score, 0.0);
}
#[test]
fn test_calculate_size_appropriateness_ideal() {
let score = calculate_size_appropriateness(3, 60, 40);
assert!(score >= 0.8, "Expected >= 0.8, got {}", score);
}
#[test]
fn test_calculate_size_appropriateness_large() {
let score = calculate_size_appropriateness(20, 800, 200);
assert!(score <= 0.2, "Expected <= 0.2, got {}", score);
}
#[test]
fn test_calculate_size_appropriateness_empty() {
let score = calculate_size_appropriateness(0, 0, 0);
assert_eq!(score, 0.0);
}
#[test]
fn test_calculate_test_presence_both() {
let files = vec![
"src/main.rs".to_string(),
"src/lib.rs".to_string(),
"tests/test_main.rs".to_string(),
];
let score = calculate_test_presence(&files);
assert_eq!(score, 1.0);
}
#[test]
fn test_calculate_test_presence_source_only() {
let files = vec!["src/main.rs".to_string(), "src/lib.rs".to_string()];
let score = calculate_test_presence(&files);
assert!(score < 0.5, "Expected < 0.5, got {}", score);
}
#[test]
fn test_calculate_test_presence_test_only() {
let files = vec![
"tests/test_main.rs".to_string(),
"src/module_test.rs".to_string(),
];
let score = calculate_test_presence(&files);
assert!(score >= 0.7, "Expected >= 0.7, got {}", score);
}
#[test]
fn test_calculate_test_presence_empty() {
let score = calculate_test_presence(&[]);
assert_eq!(score, 0.0);
}
#[test]
fn test_calculate_atomicity_single_file() {
let files = vec!["src/main.rs".to_string()];
let coupling = ChangeCouplingAnalysis::default();
let score = calculate_atomicity(&files, &coupling);
assert_eq!(score, 1.0);
}
#[test]
fn test_calculate_atomicity_same_directory() {
let files = vec![
"src/app.rs".to_string(),
"src/lib.rs".to_string(),
"src/main.rs".to_string(),
];
let coupling = ChangeCouplingAnalysis::default();
let score = calculate_atomicity(&files, &coupling);
assert!(score >= 0.5, "Expected >= 0.5, got {}", score);
}
#[test]
fn test_calculate_atomicity_scattered() {
let files = vec![
"src/app.rs".to_string(),
"tests/test.rs".to_string(),
"docs/readme.md".to_string(),
"config/settings.toml".to_string(),
];
let coupling = ChangeCouplingAnalysis::default();
let score = calculate_atomicity(&files, &coupling);
assert!(score < 0.5, "Expected < 0.5, got {}", score);
}
#[test]
fn test_calculate_quality_scores_empty() {
let events: Vec<&GitEvent> = vec![];
let coupling = ChangeCouplingAnalysis::default();
let analysis = calculate_quality_scores(&events, |_| None, &coupling);
assert_eq!(analysis.total_commits, 0);
assert_eq!(analysis.commits.len(), 0);
assert_eq!(analysis.avg_score, 0.0);
}
#[test]
fn test_calculate_quality_scores_single_commit() {
let events = vec![create_test_event_for_quality(
"abc1234",
"feat: add authentication feature",
50,
10,
)];
let refs: Vec<&GitEvent> = events.iter().collect();
let coupling = ChangeCouplingAnalysis::default();
let analysis = calculate_quality_scores(
&refs,
|_| {
Some(vec![
"src/auth.rs".to_string(),
"tests/auth_test.rs".to_string(),
])
},
&coupling,
);
assert_eq!(analysis.total_commits, 1);
assert_eq!(analysis.commits.len(), 1);
let commit = &analysis.commits[0];
assert_eq!(commit.commit_hash, "abc1234");
assert!(commit.score > 0.0);
assert!(commit.message_score > 0.0);
assert!(commit.size_score > 0.0);
assert!(commit.test_score > 0.0);
}
#[test]
fn test_calculate_quality_scores_sorted_by_score() {
let events = vec![
create_test_event_for_quality("low", "wip", 500, 500), create_test_event_for_quality("high", "feat: excellent commit with tests", 50, 20), create_test_event_for_quality("medium", "update files", 100, 50), ];
let refs: Vec<&GitEvent> = events.iter().collect();
let coupling = ChangeCouplingAnalysis::default();
let analysis = calculate_quality_scores(
&refs,
|hash| {
if hash == "high" {
Some(vec![
"src/feature.rs".to_string(),
"tests/feature_test.rs".to_string(),
])
} else {
Some(vec!["src/main.rs".to_string()])
}
},
&coupling,
);
assert_eq!(analysis.commits.len(), 3);
assert!(analysis.commits[0].score >= analysis.commits[1].score);
assert!(analysis.commits[1].score >= analysis.commits[2].score);
}
#[test]
fn test_commit_quality_score_color() {
let mut commit = CommitQualityScore {
commit_hash: "abc".to_string(),
commit_message: "test".to_string(),
author: "author".to_string(),
date: Local::now(),
files_changed: 0,
insertions: 0,
deletions: 0,
score: 0.0,
message_score: 0.0,
size_score: 0.0,
test_score: 0.0,
atomicity_score: 0.0,
};
commit.score = 0.9;
assert_eq!(commit.score_color(), "green");
commit.score = 0.7;
assert_eq!(commit.score_color(), "green");
commit.score = 0.6;
assert_eq!(commit.score_color(), "green");
commit.score = 0.5;
assert_eq!(commit.score_color(), "yellow");
commit.score = 0.4;
assert_eq!(commit.score_color(), "yellow");
commit.score = 0.3;
assert_eq!(commit.score_color(), "red");
commit.score = 0.2;
assert_eq!(commit.score_color(), "red");
}
#[test]
fn test_commit_quality_score_bar() {
let mut commit = CommitQualityScore {
commit_hash: "abc".to_string(),
commit_message: "test".to_string(),
author: "author".to_string(),
date: Local::now(),
files_changed: 0,
insertions: 0,
deletions: 0,
score: 0.0,
message_score: 0.0,
size_score: 0.0,
test_score: 0.0,
atomicity_score: 0.0,
};
commit.score = 0.9;
assert_eq!(commit.score_bar(), "█████");
commit.score = 0.7;
assert_eq!(commit.score_bar(), "████ ");
commit.score = 0.5;
assert_eq!(commit.score_bar(), "███ ");
commit.score = 0.3;
assert_eq!(commit.score_bar(), "██ ");
commit.score = 0.1;
assert_eq!(commit.score_bar(), "█ ");
}
#[test]
fn test_commit_quality_score_quality_level() {
let mut commit = CommitQualityScore {
commit_hash: "abc".to_string(),
commit_message: "test".to_string(),
author: "author".to_string(),
date: Local::now(),
files_changed: 0,
insertions: 0,
deletions: 0,
score: 0.0,
message_score: 0.0,
size_score: 0.0,
test_score: 0.0,
atomicity_score: 0.0,
};
commit.score = 0.9;
assert_eq!(commit.quality_level(), "Excellent");
commit.score = 0.7;
assert_eq!(commit.quality_level(), "Good");
commit.score = 0.5;
assert_eq!(commit.quality_level(), "Fair");
commit.score = 0.2;
assert_eq!(commit.quality_level(), "Poor");
}
#[test]
fn test_quality_analysis_high_low_counts() {
let events = vec![
create_test_event_for_quality("high1", "feat: great feature", 30, 10),
create_test_event_for_quality("high2", "fix: important fix", 20, 5),
create_test_event_for_quality("low1", "x", 1000, 1000),
create_test_event_for_quality("low2", ".", 2000, 500),
];
let refs: Vec<&GitEvent> = events.iter().collect();
let coupling = ChangeCouplingAnalysis::default();
let analysis = calculate_quality_scores(
&refs,
|hash| {
if hash == "high1" || hash == "high2" {
Some(vec![
"src/feature.rs".to_string(),
"tests/feature_test.rs".to_string(),
])
} else {
Some((0..50).map(|i| format!("file{}.rs", i)).collect())
}
},
&coupling,
);
assert_eq!(analysis.total_commits, 4);
assert!(analysis.high_quality_count > 0 || analysis.low_quality_count > 0);
}
}