use std::collections::{BTreeMap, HashMap, HashSet};
use rusqlite::{params, Connection};
use tracing::warn;
use crate::classify::errors::{ClassifyError, Result};
use crate::classify::pipeline::ClassificationPipeline;
use crate::classify::repo_map_keys::{parse_key, MapKey, RepoKeys};
use crate::classify::taxonomy::{canonical_category, TaxonomyRegistry};
use crate::classify::tiers::regex_tier::RegexMatcher;
use crate::classify::tiers::ClassificationResult;
use crate::classify::trace::{RuleTrace, TraceTier, TracedVerdict};
use crate::core::config::{RepoMapConfig, RepoMapMode};
use crate::core::models::ClassificationMethod;
use super::pipeline_db::CommitRow;
#[derive(Debug, Clone)]
struct Floor {
exceptions: Vec<String>,
min_confidence: f64,
}
#[derive(Debug, Clone, Default)]
pub(crate) struct RepoCategoryMap {
repos: HashMap<String, RepoKeys>,
keys: BTreeMap<String, MapKey>,
floor: Option<Floor>,
}
const KEY: &str = "classification.repo_categories";
impl RepoCategoryMap {
pub(crate) fn checked(
raw: &HashMap<String, String>,
settings: &RepoMapConfig,
known: &[String],
configured: &HashSet<&str>,
) -> Result<Self> {
let sorted: BTreeMap<&String, &String> = raw.iter().collect();
let globs: Vec<&str> = sorted
.keys()
.filter(|k| k.contains('*'))
.map(|k| k.as_str())
.collect();
if !globs.is_empty() {
return Err(ClassifyError::Config(format!(
"{KEY} keys are exact repository names, not globs: {}",
globs.join(", ")
)));
}
let mut keys = BTreeMap::new();
let mut malformed = Vec::new();
let mut seen: BTreeMap<MapKey, &str> = BTreeMap::new();
let mut duplicates = Vec::new();
for raw_key in sorted.keys() {
match parse_key(raw_key, configured) {
Ok(key) => {
if let Some(first) = seen.insert(key.clone(), raw_key.as_str()) {
duplicates.push(format!("'{first}' and '{raw_key}'"));
}
keys.insert(raw_key.to_string(), key);
}
Err(why) => malformed.push(format!("'{raw_key}' ({why})")),
}
}
if !malformed.is_empty() {
return Err(ClassifyError::Config(format!(
"{KEY} keys must be '<repo>' or '<repo>:<prefix>': {}",
malformed.join(", ")
)));
}
if !duplicates.is_empty() {
return Err(ClassifyError::Config(format!(
"{KEY} keys name the same repository and prefix: {}",
duplicates.join(", ")
)));
}
let mut repos: HashMap<String, RepoKeys> = HashMap::new();
let mut unknown = Vec::new();
for (raw_key, category) in sorted {
match known.iter().find(|k| k.eq_ignore_ascii_case(category)) {
Some(name) => {
let key = keys.get(raw_key.as_str()).ok_or_else(|| {
ClassifyError::Config(format!("{KEY} key '{raw_key}' was not parsed"))
})?;
repos
.entry(key.repo.clone())
.or_default()
.insert(key.prefix.clone(), name.clone());
}
None => unknown.push(format!("repository '{raw_key}' -> category '{category}'")),
}
}
if !unknown.is_empty() {
return Err(ClassifyError::Config(format!(
"{KEY} names categories this config does not know: {}",
unknown.join(", ")
)));
}
Ok(Self {
repos,
keys,
floor: Self::checked_floor(settings, known)?,
})
}
fn checked_floor(settings: &RepoMapConfig, known: &[String]) -> Result<Option<Floor>> {
if settings.mode != RepoMapMode::Floor {
return Ok(None);
}
let min = settings.min_confidence;
if !(0.0..=1.0).contains(&min) {
return Err(ClassifyError::Config(format!(
"classification.repo_map.min_confidence must be within [0, 1], got {min}"
)));
}
let exceptions = settings.exceptions();
let known: HashSet<String> = known.iter().map(|k| canonical_category(k)).collect();
let unknown: Vec<&str> = exceptions
.iter()
.filter(|e| !known.contains(&canonical_category(e)))
.map(String::as_str)
.collect();
if settings.exceptions.is_some() && !unknown.is_empty() {
return Err(ClassifyError::Config(format!(
"classification.repo_map.exceptions names categories this config does not \
know: {}",
unknown.join(", ")
)));
}
for e in unknown {
warn!(
"classification.repo_map default exception '{e}' is a category this config \
does not know; no verdict can match it"
);
}
Ok(Some(Floor {
exceptions: exceptions.iter().map(|e| canonical_category(e)).collect(),
min_confidence: min,
}))
}
pub(crate) fn is_floor(&self) -> bool {
self.floor.is_some()
}
pub(crate) fn keeps(&self, category: &str, confidence: f64) -> bool {
self.floor.as_ref().is_some_and(|f| {
confidence >= f.min_confidence && f.exceptions.contains(&canonical_category(category))
})
}
pub(crate) fn load_paths<'a>(
&self,
conn: &Connection,
commits: impl IntoIterator<Item = (i64, &'a str)>,
) -> Result<HashMap<i64, Vec<String>>> {
let ids: Vec<i64> = commits
.into_iter()
.filter(|(_, repo)| self.repos.get(*repo).is_some_and(RepoKeys::has_prefixes))
.map(|(id, _)| id)
.collect();
if ids.is_empty() {
return Ok(HashMap::new());
}
crate::core::db::commit_context::load_paths(conn, &ids)
.map_err(|e| crate::core::TgaError::from(e).into())
}
pub(crate) fn traced(
&self,
repo: &str,
is_merge: bool,
paths: &[String],
message: &str,
taxonomy: &TaxonomyRegistry,
) -> Option<TracedVerdict> {
if is_merge {
return None;
}
let (prefix, category) = self.repos.get(repo)?.resolve(paths)?;
let verdict = ClassificationResult {
top_level: taxonomy.resolve(category),
category: category.to_string(),
subcategory: None,
confidence: 1.0,
method: ClassificationMethod::RepoMap,
ticket_id: RegexMatcher::extract_ticket_id(message),
complexity: None,
};
let rule_id = match prefix {
None => format!("repo_map:{repo}"),
Some(p) => format!("repo_map:{repo}:{p}"),
};
let trace = RuleTrace::new(TraceTier::RepoMap, rule_id);
Some(TracedVerdict { verdict, trace })
}
fn verdict_of(
&self,
c: &CommitRow,
paths: &HashMap<i64, Vec<String>>,
taxonomy: &TaxonomyRegistry,
) -> Option<ClassificationResult> {
let p = paths.get(&c.id).map_or(&[][..], Vec::as_slice);
self.traced(&c.repository, c.is_merge, p, &c.message, taxonomy)
.map(|t| t.verdict)
}
pub(super) fn verdicts(
&self,
commits: &[CommitRow],
paths: &HashMap<i64, Vec<String>>,
taxonomy: &TaxonomyRegistry,
) -> HashMap<i64, ClassificationResult> {
if self.repos.is_empty() || self.is_floor() {
return HashMap::new();
}
commits
.iter()
.filter_map(|c| self.verdict_of(c, paths, taxonomy).map(|v| (c.id, v)))
.collect()
}
pub(super) fn apply_floor(
&self,
commits: &[CommitRow],
paths: &HashMap<i64, Vec<String>>,
results: &mut [ClassificationResult],
taxonomy: &TaxonomyRegistry,
) {
if !self.is_floor() {
return;
}
for (c, r) in commits.iter().zip(results.iter_mut()) {
if self.keeps(&r.category, r.confidence) {
continue;
}
if let Some(v) = self.verdict_of(c, paths, taxonomy) {
*r = ClassificationResult {
complexity: r.complexity,
..v
};
}
}
}
pub(crate) fn warn_unmatched_keys(&self, conn: &Connection) -> Result<()> {
if self.keys.is_empty() {
return Ok(());
}
let db = |e: rusqlite::Error| ClassifyError::from(crate::core::TgaError::from(e));
let mut stmt = conn
.prepare("SELECT DISTINCT repository FROM commits")
.map_err(db)?;
let stored: HashSet<String> = stmt
.query_map([], |r| r.get(0))
.map_err(db)?
.collect::<std::result::Result<_, _>>()
.map_err(db)?;
for (raw, key) in &self.keys {
if !stored.contains(&key.repo) {
warn!("{KEY} key '{raw}' matches no stored repository");
continue;
}
let Some(prefix) = &key.prefix else { continue };
let under = format!("{prefix}/");
let hit: bool = conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM files f JOIN commits c ON c.id = f.commit_id \
WHERE c.repository = ?1 AND (f.path = ?2 OR substr(f.path, 1, ?3) = ?4))",
params![key.repo, prefix, under.chars().count() as i64, under],
|r| r.get(0),
)
.map_err(db)?;
if !hit {
warn!(
"{KEY} key '{raw}' matches no stored path under '{prefix}' in repository \
'{}'",
key.repo
);
}
}
Ok(())
}
}
impl ClassificationPipeline {
pub(crate) fn repo_category_map(&self) -> Result<RepoCategoryMap> {
let (raw, settings) = match self.config.classification.as_ref() {
Some(c) if !c.repo_categories.is_empty() => (&c.repo_categories, &c.repo_map),
_ => return Ok(RepoCategoryMap::default()),
};
let known = match self.llm_categories()? {
Some(defs) => defs.into_iter().map(|d| d.name).collect(),
None => self.known_categories()?,
};
let configured: HashSet<&str> = self
.config
.repositories
.iter()
.filter_map(|r| r.name.as_deref())
.collect();
RepoCategoryMap::checked(raw, settings, &known, &configured)
}
}