use std::collections::HashMap;
use std::sync::OnceLock;
use regex::Regex;
use crate::classify::taxonomy::{TaxonomyRegistry, TopLevelCategory};
use crate::classify::tiers::ClassificationResult;
use crate::core::models::ClassificationMethod;
fn jira_key_re() -> Option<&'static Regex> {
static RE: OnceLock<Option<Regex>> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"\b([A-Z][A-Z0-9]+)-\d+\b").ok())
.as_ref()
}
pub const DEFAULT_PROJECT_MAPPING_CONFIDENCE: f64 = 0.88;
pub struct JiraProjectTier {
mappings: HashMap<String, String>,
taxonomy: TaxonomyRegistry,
confidence: f64,
}
impl JiraProjectTier {
pub fn new(mappings: HashMap<String, String>) -> Self {
Self::with_taxonomy(mappings, TaxonomyRegistry::with_builtins())
}
pub fn with_taxonomy(mappings: HashMap<String, String>, taxonomy: TaxonomyRegistry) -> Self {
Self::with_taxonomy_and_confidence(mappings, taxonomy, DEFAULT_PROJECT_MAPPING_CONFIDENCE)
}
pub fn with_taxonomy_and_confidence(
mappings: HashMap<String, String>,
taxonomy: TaxonomyRegistry,
confidence: f64,
) -> Self {
let normalized = mappings
.into_iter()
.map(|(k, v)| (k.to_uppercase(), v))
.collect();
Self {
mappings: normalized,
taxonomy,
confidence,
}
}
pub fn mappings(&self) -> &HashMap<String, String> {
&self.mappings
}
pub fn is_empty(&self) -> bool {
self.mappings.is_empty()
}
pub fn classify(&self, commit_message: &str) -> Option<ClassificationResult> {
if self.mappings.is_empty() {
return None;
}
let re = jira_key_re()?;
let caps = re.captures(commit_message)?;
let project = caps.get(1)?.as_str().to_uppercase();
let category = self.mappings.get(&project)?.clone();
let ticket_id = caps.get(0).map(|m| m.as_str().to_string());
let top_level = self
.taxonomy
.resolve(&category)
.unwrap_or(TopLevelCategory::Unknown);
Some(ClassificationResult {
category,
subcategory: None,
top_level: Some(top_level),
confidence: self.confidence,
method: ClassificationMethod::ExternalSource,
ticket_id,
complexity: None,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture() -> JiraProjectTier {
let mut m = HashMap::new();
m.insert("INFRA".to_string(), "platform".to_string());
m.insert("DATA".to_string(), "feature".to_string());
JiraProjectTier::new(m)
}
#[test]
fn hit_returns_mapped_category() {
let t = fixture();
let r = t.classify("INFRA-42 fix nginx config").expect("hit");
assert_eq!(r.category, "platform");
assert!(
(r.confidence - DEFAULT_PROJECT_MAPPING_CONFIDENCE).abs() < 1e-9,
"default confidence is {DEFAULT_PROJECT_MAPPING_CONFIDENCE}"
);
assert_eq!(r.ticket_id.as_deref(), Some("INFRA-42"));
}
#[test]
fn confidence_override_threads_through() {
let mut m = HashMap::new();
m.insert("INFRA".to_string(), "platform".to_string());
let t = JiraProjectTier::with_taxonomy_and_confidence(
m,
TaxonomyRegistry::with_builtins(),
0.5,
);
let r = t.classify("INFRA-1 any").expect("hit");
assert!((r.confidence - 0.5).abs() < 1e-9);
}
#[test]
fn unmapped_project_returns_none() {
let t = fixture();
assert!(t.classify("FOO-1 some change").is_none());
}
#[test]
fn no_ticket_returns_none() {
let t = fixture();
assert!(t.classify("fix: something without a ticket").is_none());
}
#[test]
fn empty_mappings_short_circuits() {
let t = JiraProjectTier::new(HashMap::new());
assert!(t.is_empty());
assert!(t.classify("INFRA-1 anything").is_none());
}
#[test]
fn mappings_normalized_to_uppercase() {
let mut m = HashMap::new();
m.insert("infra".to_string(), "platform".to_string());
let t = JiraProjectTier::new(m);
let r = t.classify("INFRA-7 patch").expect("hit");
assert_eq!(r.category, "platform");
}
}