#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum ChangeIntent {
Feature,
Fix,
Refactor,
Docs,
Test,
Chore,
#[default]
Unknown,
}
impl ChangeIntent {
pub fn icon(&self) -> &'static str {
match self {
Self::Feature => "\u{2726}", Self::Fix => "\u{2727}", Self::Refactor => "\u{21bb}", Self::Docs => "\u{25c7}", Self::Test => "\u{25c8}", Self::Chore => "\u{25cb}", Self::Unknown => "\u{00b7}", }
}
pub fn label(&self) -> &'static str {
match self {
Self::Feature => "Feature",
Self::Fix => "Fix",
Self::Refactor => "Refactor",
Self::Docs => "Docs",
Self::Test => "Test",
Self::Chore => "Chore",
Self::Unknown => "Unknown",
}
}
}
pub fn classify_intent(message: &str, files: &[String]) -> ChangeIntent {
let msg_lower = message.to_lowercase();
let trimmed = msg_lower.trim();
if trimmed.starts_with("feat:") || trimmed.starts_with("feat(") {
return ChangeIntent::Feature;
}
if trimmed.starts_with("fix:") || trimmed.starts_with("fix(") {
return ChangeIntent::Fix;
}
if trimmed.starts_with("refactor:") || trimmed.starts_with("refactor(") {
return ChangeIntent::Refactor;
}
if trimmed.starts_with("docs:") || trimmed.starts_with("docs(") {
return ChangeIntent::Docs;
}
if trimmed.starts_with("test:") || trimmed.starts_with("test(") {
return ChangeIntent::Test;
}
if trimmed.starts_with("chore:")
|| trimmed.starts_with("chore(")
|| trimmed.starts_with("ci:")
|| trimmed.starts_with("ci(")
|| trimmed.starts_with("build:")
|| trimmed.starts_with("build(")
|| trimmed.starts_with("style:")
|| trimmed.starts_with("style(")
|| trimmed.starts_with("perf:")
|| trimmed.starts_with("perf(")
{
return ChangeIntent::Chore;
}
if msg_lower.contains("fix")
|| msg_lower.contains("bug")
|| msg_lower.contains("error")
|| msg_lower.contains("patch")
|| msg_lower.contains("hotfix")
{
return ChangeIntent::Fix;
}
if msg_lower.contains("add")
|| msg_lower.contains("new")
|| msg_lower.contains("implement")
|| msg_lower.contains("feature")
{
return ChangeIntent::Feature;
}
if msg_lower.contains("refactor")
|| msg_lower.contains("restructure")
|| msg_lower.contains("reorganize")
|| msg_lower.contains("clean")
{
return ChangeIntent::Refactor;
}
if !files.is_empty() {
let test_count = files.iter().filter(|f| is_test_file(f)).count();
let doc_count = files.iter().filter(|f| is_doc_file(f)).count();
if test_count > 0 && test_count == files.len() {
return ChangeIntent::Test;
}
if doc_count > 0 && doc_count == files.len() {
return ChangeIntent::Docs;
}
}
ChangeIntent::Unknown
}
fn is_test_file(path: &str) -> bool {
let lower = path.to_lowercase();
lower.contains("/tests/")
|| lower.contains("_test.")
|| lower.contains(".test.")
|| lower.contains(".spec.")
|| lower.starts_with("test_")
|| lower.starts_with("tests/")
}
fn is_doc_file(path: &str) -> bool {
let lower = path.to_lowercase();
lower.ends_with(".md")
|| lower.starts_with("docs/")
|| lower.contains("/docs/")
|| lower.starts_with("readme")
|| lower.contains("license")
|| lower.contains("changelog")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_classify_conventional_commit_feat() {
assert_eq!(
classify_intent("feat: add login", &[]),
ChangeIntent::Feature
);
}
#[test]
fn test_classify_conventional_commit_fix() {
assert_eq!(
classify_intent("fix: resolve crash", &[]),
ChangeIntent::Fix
);
}
#[test]
fn test_classify_conventional_commit_refactor() {
assert_eq!(
classify_intent("refactor: simplify auth", &[]),
ChangeIntent::Refactor
);
}
#[test]
fn test_classify_conventional_commit_docs() {
assert_eq!(
classify_intent("docs: update README", &[]),
ChangeIntent::Docs
);
}
#[test]
fn test_classify_conventional_commit_test() {
assert_eq!(
classify_intent("test: add unit tests", &[]),
ChangeIntent::Test
);
}
#[test]
fn test_classify_conventional_commit_chore() {
assert_eq!(
classify_intent("chore: update deps", &[]),
ChangeIntent::Chore
);
}
#[test]
fn test_classify_keyword_fix() {
assert_eq!(classify_intent("Fix the login bug", &[]), ChangeIntent::Fix);
}
#[test]
fn test_classify_keyword_add() {
assert_eq!(
classify_intent("Add new dashboard", &[]),
ChangeIntent::Feature
);
}
#[test]
fn test_classify_file_pattern_test_only() {
let files = vec!["tests/test_auth.rs".to_string()];
assert_eq!(classify_intent("update tests", &files), ChangeIntent::Test);
}
#[test]
fn test_classify_file_pattern_docs_only() {
let files = vec!["README.md".to_string(), "docs/guide.md".to_string()];
assert_eq!(classify_intent("some message", &files), ChangeIntent::Docs);
}
#[test]
fn test_classify_unknown() {
assert_eq!(classify_intent("misc changes", &[]), ChangeIntent::Unknown);
}
#[test]
fn test_change_intent_icon_non_empty() {
for intent in [
ChangeIntent::Feature,
ChangeIntent::Fix,
ChangeIntent::Refactor,
ChangeIntent::Docs,
ChangeIntent::Test,
ChangeIntent::Chore,
ChangeIntent::Unknown,
] {
assert!(!intent.icon().is_empty());
}
}
#[test]
fn test_change_intent_label_non_empty() {
for intent in [
ChangeIntent::Feature,
ChangeIntent::Fix,
ChangeIntent::Refactor,
ChangeIntent::Docs,
ChangeIntent::Test,
ChangeIntent::Chore,
ChangeIntent::Unknown,
] {
assert!(!intent.label().is_empty());
}
}
#[test]
fn test_change_intent_default_is_unknown() {
assert_eq!(ChangeIntent::default(), ChangeIntent::Unknown);
}
#[test]
fn test_classify_conventional_commit_with_scope() {
assert_eq!(
classify_intent("feat(auth): add OAuth", &[]),
ChangeIntent::Feature
);
assert_eq!(
classify_intent("fix(ui): button alignment", &[]),
ChangeIntent::Fix
);
assert_eq!(
classify_intent("refactor(core): simplify", &[]),
ChangeIntent::Refactor
);
assert_eq!(
classify_intent("docs(api): update guide", &[]),
ChangeIntent::Docs
);
assert_eq!(
classify_intent("test(auth): add unit tests", &[]),
ChangeIntent::Test
);
}
#[test]
fn test_classify_ci_build_style_perf_as_chore() {
assert_eq!(
classify_intent("ci: update pipeline", &[]),
ChangeIntent::Chore
);
assert_eq!(
classify_intent("build: update deps", &[]),
ChangeIntent::Chore
);
assert_eq!(
classify_intent("style: format code", &[]),
ChangeIntent::Chore
);
assert_eq!(
classify_intent("perf: optimize query", &[]),
ChangeIntent::Chore
);
}
#[test]
fn test_classify_keyword_bug() {
assert_eq!(
classify_intent("Fixed a critical bug in auth", &[]),
ChangeIntent::Fix
);
}
#[test]
fn test_classify_keyword_error() {
assert_eq!(
classify_intent("Handle error in parser", &[]),
ChangeIntent::Fix
);
}
#[test]
fn test_classify_keyword_hotfix() {
assert_eq!(
classify_intent("Hotfix for production crash", &[]),
ChangeIntent::Fix
);
}
#[test]
fn test_classify_keyword_implement() {
assert_eq!(
classify_intent("Implement user dashboard", &[]),
ChangeIntent::Feature
);
}
#[test]
fn test_classify_keyword_refactor() {
assert_eq!(
classify_intent("Refactor database layer", &[]),
ChangeIntent::Refactor
);
}
#[test]
fn test_classify_keyword_clean() {
assert_eq!(
classify_intent("Clean up unused imports", &[]),
ChangeIntent::Refactor
);
}
#[test]
fn test_classify_mixed_files_not_all_test() {
let files = vec!["tests/test_auth.rs".to_string(), "src/auth.rs".to_string()];
assert_ne!(classify_intent("some message", &files), ChangeIntent::Test);
}
#[test]
fn test_classify_mixed_files_not_all_docs() {
let files = vec!["README.md".to_string(), "src/main.rs".to_string()];
assert_ne!(classify_intent("some message", &files), ChangeIntent::Docs);
}
#[test]
fn test_classify_conventional_takes_priority() {
assert_eq!(
classify_intent("feat: fix the login flow", &[]),
ChangeIntent::Feature
);
}
#[test]
fn test_is_test_file_various() {
assert!(is_test_file("tests/unit.rs"));
assert!(is_test_file("src/models/user_test.rs"));
assert!(is_test_file("src/components/Button.test.tsx"));
assert!(is_test_file("src/api/auth.spec.js"));
assert!(is_test_file("test_helper.py"));
assert!(!is_test_file("src/testing_utils.rs")); }
#[test]
fn test_is_doc_file_various() {
assert!(is_doc_file("README.md"));
assert!(is_doc_file("CHANGELOG.md"));
assert!(is_doc_file("docs/guide.md"));
assert!(is_doc_file("src/docs/api.md"));
assert!(is_doc_file("LICENSE"));
assert!(!is_doc_file("src/main.rs"));
}
#[test]
fn test_change_intent_eq_hash() {
use std::collections::HashSet;
let mut set = HashSet::new();
set.insert(ChangeIntent::Feature);
set.insert(ChangeIntent::Fix);
set.insert(ChangeIntent::Feature); assert_eq!(set.len(), 2);
}
}