use crate::classify::taxonomy::TopLevelCategory;
use crate::classify::tiers::ClassificationResult;
use crate::core::models::ClassificationMethod;
pub struct FuzzyClassifier;
impl FuzzyClassifier {
pub fn classify(&self, message: &str, is_merge: bool) -> Option<ClassificationResult> {
self.classify_traced(message, is_merge).map(|(r, _)| r)
}
pub fn classify_traced(
&self,
message: &str,
is_merge: bool,
) -> Option<(ClassificationResult, &'static str)> {
let trimmed = message.trim();
let lower = trimmed.to_lowercase();
if is_merge
|| lower.starts_with("merge pull request")
|| lower.starts_with("merge branch")
|| lower.starts_with("merge remote-tracking")
{
return Some((
ClassificationResult {
category: "merge".to_string(),
subcategory: None,
top_level: Some(TopLevelCategory::Maintenance),
confidence: 0.95,
method: ClassificationMethod::FuzzyMatch,
ticket_id: None,
complexity: None,
},
"merge",
));
}
if lower.starts_with("revert ") || lower.starts_with("revert:") {
return Some((
ClassificationResult {
category: "revert".to_string(),
subcategory: None,
top_level: Some(TopLevelCategory::Maintenance),
confidence: 0.9,
method: ClassificationMethod::FuzzyMatch,
ticket_id: None,
complexity: None,
},
"revert",
));
}
if let Some(ticket) = bare_ticket_prefix(trimmed) {
return Some((
ClassificationResult {
category: "feature".to_string(),
subcategory: Some("ticketed".to_string()),
top_level: Some(TopLevelCategory::Feature),
confidence: 0.6,
method: ClassificationMethod::FuzzyMatch,
ticket_id: Some(ticket),
complexity: None,
},
"ticket_prefix",
));
}
if trimmed.len() < 12 && !trimmed.is_empty() {
return Some((
ClassificationResult {
category: "chore".to_string(),
subcategory: None,
top_level: Some(TopLevelCategory::Maintenance),
confidence: 0.4,
method: ClassificationMethod::FuzzyMatch,
ticket_id: None,
complexity: None,
},
"short_message",
));
}
None
}
}
fn bare_ticket_prefix(message: &str) -> Option<String> {
let first = message.split_whitespace().next()?;
let candidate = first.trim_end_matches([':', '-', ',']);
let mut parts = candidate.split('-');
let project = parts.next()?;
let number = parts.next()?;
if parts.next().is_some() {
return None;
}
if project.is_empty() || number.is_empty() {
return None;
}
if !project
.chars()
.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit())
{
return None;
}
if !project.chars().next()?.is_ascii_uppercase() {
return None;
}
if !number.chars().all(|c| c.is_ascii_digit()) {
return None;
}
Some(format!("{project}-{number}"))
}