use crate::metrics::{CategoryResult, MetricValue, RawValue};
use crate::snapshot::RepoSnapshot;
pub fn compute_hygiene(
snapshot: &RepoSnapshot,
thresholds: &crate::config::HygieneThresholds,
) -> CategoryResult {
let metrics = vec![
commit_message_quality(snapshot, thresholds),
history_cleanliness(snapshot, thresholds),
gitignore_coverage(snapshot, thresholds),
firefighting_ratio(snapshot, thresholds),
];
CategoryResult {
name: "Git Hygiene".to_string(),
score: 0,
metrics,
}
.compute_score()
}
const CONVENTIONAL_PREFIXES: &[&str] = &[
"feat:",
"fix:",
"docs:",
"style:",
"refactor:",
"perf:",
"test:",
"chore:",
"ci:",
"build:",
"revert:",
"feat(",
"fix(",
"docs(",
"style(",
"refactor(",
"perf(",
"test(",
"chore(",
"ci(",
"build(",
"revert(",
];
fn commit_message_quality(
snapshot: &RepoSnapshot,
_thresholds: &crate::config::HygieneThresholds,
) -> MetricValue {
if snapshot.commits.is_empty() {
return MetricValue {
name: "Commit message quality".to_string(),
description: "No commits".to_string(),
raw_value: RawValue::Text("N/A".to_string()),
score: None,
};
}
let window_commits: Vec<_> = snapshot
.commits
.iter()
.filter(|c| snapshot.time_window.contains(&c.timestamp))
.collect();
if window_commits.is_empty() {
return MetricValue {
name: "Commit message quality".to_string(),
description: "No commits in window".to_string(),
raw_value: RawValue::Text("N/A".to_string()),
score: None,
};
}
let total = window_commits.len();
let good = window_commits
.iter()
.filter(|c| is_good_commit_message(&c.message))
.count();
let conventional = window_commits
.iter()
.filter(|c| is_conventional_commit(&c.message))
.count();
let quality_pct = (good as f64 / total as f64) * 100.0;
let conventional_pct = (conventional as f64 / total as f64) * 100.0;
let score = if quality_pct > 80.0 {
90
} else if quality_pct > 60.0 {
70
} else if quality_pct > 40.0 {
50
} else {
30
};
MetricValue {
name: "Commit message quality".to_string(),
description: format!(
"{:.0}% good messages, {:.0}% conventional commits",
quality_pct, conventional_pct
),
raw_value: RawValue::Percentage(quality_pct),
score: Some(score),
}
}
fn is_good_commit_message(msg: &str) -> bool {
let first_line = msg.lines().next().unwrap_or("");
if first_line.len() < 10 {
return false;
}
let subject = if let Some(pos) = first_line.find(": ") {
&first_line[pos + 2..]
} else {
first_line
};
if subject.is_empty() {
return false;
}
let first_char = subject.chars().next().unwrap();
if !first_char.is_uppercase() && !is_conventional_commit(first_line) {
return false;
}
let lower = first_line.to_lowercase();
if lower == "wip" || lower == "fix" || lower == "update" || lower == "changes" {
return false;
}
true
}
fn is_conventional_commit(msg: &str) -> bool {
let lower = msg.to_lowercase();
CONVENTIONAL_PREFIXES.iter().any(|p| lower.starts_with(p))
}
fn history_cleanliness(
snapshot: &RepoSnapshot,
_thresholds: &crate::config::HygieneThresholds,
) -> MetricValue {
if snapshot.commits.is_empty() {
return MetricValue {
name: "History cleanliness".to_string(),
description: "No commits".to_string(),
raw_value: RawValue::Text("N/A".to_string()),
score: None,
};
}
let total = snapshot.commits.len();
let merge_count = snapshot.commits.iter().filter(|c| c.is_merge).count();
let octopus_merges = snapshot
.commits
.iter()
.filter(|c| c.parent_count > 2)
.count();
let empty_messages = snapshot
.commits
.iter()
.filter(|c| c.message.trim().is_empty())
.count();
let merge_pct = if total > 0 {
(merge_count as f64 / total as f64) * 100.0
} else {
0.0
};
let issues = octopus_merges + empty_messages;
let score = if issues > 5 || merge_pct > 60.0 {
30
} else if issues > 2 || merge_pct > 40.0 {
55
} else if merge_pct > 20.0 {
75
} else {
90
};
MetricValue {
name: "History cleanliness".to_string(),
description: format!(
"{:.0}% merges, {} octopus merges, {} empty messages",
merge_pct, octopus_merges, empty_messages
),
raw_value: RawValue::Count(issues),
score: Some(score),
}
}
const SUSPICIOUS_PATTERNS: &[&str] = &[
".env",
".env.",
"credentials",
"secret",
".key",
".pem",
".p12",
".pfx",
"node_modules/",
"__pycache__/",
".DS_Store",
"Thumbs.db",
".pyc",
];
fn gitignore_coverage(
snapshot: &RepoSnapshot,
_thresholds: &crate::config::HygieneThresholds,
) -> MetricValue {
let suspicious: Vec<String> = snapshot
.files
.iter()
.filter(|f| {
let path_str = f.path.to_string_lossy().to_lowercase();
let file_name = f
.path
.file_name()
.map(|n| n.to_string_lossy().to_lowercase())
.unwrap_or_default();
SUSPICIOUS_PATTERNS
.iter()
.any(|pat| path_str.contains(pat) || file_name.ends_with(pat) || file_name == *pat)
})
.map(|f| f.path.display().to_string())
.collect();
let count = suspicious.len();
let score = match count {
0 => 100,
1..=2 => 70,
3..=5 => 45,
_ => 20,
};
MetricValue {
name: "Gitignore coverage".to_string(),
description: if count > 0 {
format!("{} suspicious tracked files", count)
} else {
"No suspicious tracked files".to_string()
},
raw_value: if suspicious.is_empty() {
RawValue::Count(0)
} else {
RawValue::List(suspicious)
},
score: Some(score),
}
}
const FIREFIGHTING_KEYWORDS: &[&str] = &["revert", "hotfix", "emergency", "rollback"];
fn firefighting_ratio(
snapshot: &RepoSnapshot,
_thresholds: &crate::config::HygieneThresholds,
) -> MetricValue {
let window_commits: Vec<_> = snapshot
.commits
.iter()
.filter(|c| !c.is_merge && snapshot.time_window.contains(&c.timestamp))
.collect();
if window_commits.is_empty() {
return MetricValue {
name: "Firefighting ratio".to_string(),
description: "No commits in window".to_string(),
raw_value: RawValue::Text("N/A".to_string()),
score: None,
};
}
let firefighting = window_commits
.iter()
.filter(|c| {
let msg = c.message.to_lowercase();
FIREFIGHTING_KEYWORDS.iter().any(|kw| msg.contains(kw))
})
.count();
let total = window_commits.len();
let pct = (firefighting as f64 / total as f64) * 100.0;
let score = if pct < 2.0 {
90
} else if pct < 5.0 {
75
} else if pct < 10.0 {
55
} else if pct < 20.0 {
35
} else {
20
};
MetricValue {
name: "Firefighting ratio".to_string(),
description: format!(
"{firefighting} firefighting commits ({pct:.1}% of {total} non-merge commits)"
),
raw_value: RawValue::Percentage(pct),
score: Some(score),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::snapshot::*;
use chrono::{Duration, Utc};
use std::path::PathBuf;
#[test]
fn firefighting_ratio_detects_reactive_commits() {
let mut snapshot = RepoSnapshot::new(
PathBuf::from("/tmp"),
"test".into(),
"main".into(),
TimeWindow::default(),
);
let now = Utc::now();
let messages = [
"feat: add login page", "revert: undo bad deploy", "fix: typo in README", "hotfix: prod is down", "refactor: clean up modules", ];
for (i, msg) in messages.iter().enumerate() {
snapshot.commits.push(Commit {
id: CommitId(i as u32),
author: 0,
timestamp: now - Duration::days(i as i64 + 1),
message: msg.to_string(),
files_changed: vec![],
is_merge: false,
parent_count: 1,
});
}
let result = firefighting_ratio(&snapshot, &crate::config::HygieneThresholds::default());
match result.raw_value {
RawValue::Percentage(p) => assert!((p - 40.0).abs() < 1.0, "Expected 40%, got {}", p),
_ => panic!("Expected Percentage"),
}
assert!(
result.score.unwrap() <= 35,
"40% firefighting should score ≤35, got {:?}",
result.score
);
}
#[test]
fn firefighting_ratio_ignores_merge_commits() {
let mut snapshot = RepoSnapshot::new(
PathBuf::from("/tmp"),
"test".into(),
"main".into(),
TimeWindow::default(),
);
let now = Utc::now();
snapshot.commits = vec![
Commit {
id: CommitId(0),
author: 0,
timestamp: now - Duration::days(1),
message: "Merge branch main".into(),
files_changed: vec![],
is_merge: true,
parent_count: 2,
},
Commit {
id: CommitId(1),
author: 0,
timestamp: now - Duration::days(2),
message: "revert bad change".into(),
files_changed: vec![],
is_merge: false,
parent_count: 1,
},
Commit {
id: CommitId(2),
author: 0,
timestamp: now - Duration::days(3),
message: "feat: new feature".into(),
files_changed: vec![],
is_merge: false,
parent_count: 1,
},
];
let result = firefighting_ratio(&snapshot, &crate::config::HygieneThresholds::default());
match result.raw_value {
RawValue::Percentage(p) => assert!((p - 50.0).abs() < 1.0, "Expected 50%, got {}", p),
_ => panic!("Expected Percentage"),
}
}
#[test]
fn firefighting_ratio_all_keywords_detected() {
let now = Utc::now();
for (msg, label) in &[
("revert: undo bad deploy", "revert"),
("hotfix: prod outage", "hotfix"),
("emergency: patch xss", "emergency"),
("rollback: bad migration", "rollback"),
] {
let mut snapshot = RepoSnapshot::new(
PathBuf::from("/tmp"),
"test".into(),
"main".into(),
TimeWindow::default(),
);
snapshot.commits = vec![
Commit {
id: CommitId(0),
author: 0,
timestamp: now - Duration::days(1),
message: msg.to_string(),
files_changed: vec![],
is_merge: false,
parent_count: 1,
},
Commit {
id: CommitId(1),
author: 0,
timestamp: now - Duration::days(2),
message: "feat: normal commit".into(),
files_changed: vec![],
is_merge: false,
parent_count: 1,
},
];
let result =
firefighting_ratio(&snapshot, &crate::config::HygieneThresholds::default());
match result.raw_value {
RawValue::Percentage(p) => assert!(
(p - 50.0).abs() < 1.0,
"keyword '{}' should yield 50%, got {}",
label,
p
),
_ => panic!("Expected Percentage for keyword '{}'", label),
}
}
}
#[test]
fn firefighting_ratio_zero_percent_scores_highest() {
let mut snapshot = RepoSnapshot::new(
PathBuf::from("/tmp"),
"test".into(),
"main".into(),
TimeWindow::default(),
);
let now = Utc::now();
snapshot.commits = vec![
Commit {
id: CommitId(0),
author: 0,
timestamp: now - Duration::days(1),
message: "feat: add login".into(),
files_changed: vec![],
is_merge: false,
parent_count: 1,
},
Commit {
id: CommitId(1),
author: 0,
timestamp: now - Duration::days(2),
message: "refactor: extract module".into(),
files_changed: vec![],
is_merge: false,
parent_count: 1,
},
];
let result = firefighting_ratio(&snapshot, &crate::config::HygieneThresholds::default());
assert_eq!(result.score, Some(90), "0% firefighting should score 90");
}
#[test]
fn firefighting_ratio_returns_na_when_no_commits_in_window() {
let snapshot = RepoSnapshot::new(
PathBuf::from("/tmp"),
"test".into(),
"main".into(),
TimeWindow::default(),
);
let result = firefighting_ratio(&snapshot, &crate::config::HygieneThresholds::default());
match result.raw_value {
RawValue::Text(ref s) => assert_eq!(s, "N/A"),
_ => panic!("Expected Text(N/A) for empty commit list"),
}
assert_eq!(result.score, None);
}
#[test]
fn firefighting_ratio_is_case_insensitive() {
let mut snapshot = RepoSnapshot::new(
PathBuf::from("/tmp"),
"test".into(),
"main".into(),
TimeWindow::default(),
);
let now = Utc::now();
snapshot.commits = vec![Commit {
id: CommitId(0),
author: 0,
timestamp: now - Duration::days(1),
message: "HOTFIX: PROD IS ON FIRE".into(),
files_changed: vec![],
is_merge: false,
parent_count: 1,
}];
let result = firefighting_ratio(&snapshot, &crate::config::HygieneThresholds::default());
match result.raw_value {
RawValue::Percentage(p) => assert!((p - 100.0).abs() < 1.0),
_ => panic!("Expected Percentage"),
}
}
#[test]
fn commit_message_quality_scores() {
let mut snapshot = RepoSnapshot::new(
PathBuf::from("/tmp"),
"test".into(),
"main".into(),
TimeWindow::default(),
);
let now = Utc::now();
let messages = [
"Add login feature with OAuth support", "fix", "Update README with installation steps", "wip", ];
for (i, msg) in messages.iter().enumerate() {
snapshot.commits.push(Commit {
id: CommitId(i as u32),
author: 0,
timestamp: now - Duration::days(i as i64 + 1),
message: msg.to_string(),
files_changed: vec![],
is_merge: false,
parent_count: 1,
});
}
let result =
commit_message_quality(&snapshot, &crate::config::HygieneThresholds::default());
match result.raw_value {
RawValue::Percentage(p) => assert!((p - 50.0).abs() < 1.0, "Expected ~50%, got {}", p),
_ => panic!("Expected Percentage"),
}
}
#[test]
fn history_cleanliness_flags_issues() {
let mut snapshot = RepoSnapshot::new(
PathBuf::from("/tmp"),
"test".into(),
"main".into(),
TimeWindow::default(),
);
let now = Utc::now();
snapshot.commits = vec![
Commit {
id: CommitId(0),
author: 0,
timestamp: now,
message: "msg".into(),
files_changed: vec![],
is_merge: true,
parent_count: 3, },
Commit {
id: CommitId(1),
author: 0,
timestamp: now,
message: "".into(), files_changed: vec![],
is_merge: false,
parent_count: 1,
},
Commit {
id: CommitId(2),
author: 0,
timestamp: now,
message: "Normal commit".into(),
files_changed: vec![],
is_merge: false,
parent_count: 1,
},
];
let result = history_cleanliness(&snapshot, &crate::config::HygieneThresholds::default());
match result.raw_value {
RawValue::Count(c) => assert_eq!(c, 2, "1 octopus + 1 empty = 2 issues"),
_ => panic!("Expected Count"),
}
}
#[test]
fn gitignore_detects_suspicious_files() {
let mut snapshot = RepoSnapshot::new(
PathBuf::from("/tmp"),
"test".into(),
"main".into(),
TimeWindow::default(),
);
snapshot.files = vec![
FileEntry {
path: ".env".into(),
size_bytes: 50,
is_binary: false,
depth: 0,
blob_oid: String::new(),
},
FileEntry {
path: "node_modules/package.json".into(),
size_bytes: 100,
is_binary: false,
depth: 1,
blob_oid: String::new(),
},
FileEntry {
path: "app.log".into(),
size_bytes: 1000,
is_binary: false,
depth: 0,
blob_oid: String::new(),
},
FileEntry {
path: "src/main.rs".into(),
size_bytes: 200,
is_binary: false,
depth: 1,
blob_oid: String::new(),
},
];
let result = gitignore_coverage(&snapshot, &crate::config::HygieneThresholds::default());
match &result.raw_value {
RawValue::List(items) => assert!(
items.len() >= 2,
"Expected at least 2 suspicious files, got {:?}",
items
),
_ => panic!("Expected List"),
}
assert!(result.score.unwrap() < 100);
}
}